git-changelog-smudge.pl: improve docs and comments
[git-changelog-smudge.git] / git-changelog-smudge.pl
blob809d417c1fc513306bca11fcbc491aed968cbdd7
1 #!/usr/bin/env perl
3 # git-changelog-smudge.pl -- smudge a ChangeLog file to include tag contents
4 # Copyright (C) 2017,2019,2021 Kyle J. McKay <mackyle@gmail.com>
5 # All rights reserved
7 # License GPL v2
9 # Version 1.0.1
11 use 5.008;
12 use strict;
13 use warnings;
15 use File::Basename;
16 use Getopt::Long qw(:DEFAULT GetOptionsFromString);
17 use Pod::Usage;
18 use Encode;
19 use IPC::Open2;
20 use POSIX qw(strftime);
22 close(DATA) if fileno(DATA);
23 exit(&main(@ARGV));
25 my $VERSION;
26 BEGIN {$VERSION = \"1.0.1"}
28 my $debug;
29 BEGIN {$debug = 0}
31 my %truevals;
32 BEGIN {%truevals = (
33 true => 1,
34 yes => 1,
35 on => 1
38 my $encoder;
39 BEGIN {
40 $encoder = Encode::find_encoding('Windows-1252') ||
41 Encode::find_encoding('ISO-8859-1')
42 or die "failed to load ISO-8859-1 encoder\n";
45 sub to_utf8 {
46 my $str = shift;
47 return undef unless defined $str;
48 my $result;
49 if (Encode::is_utf8($str) || utf8::decode($str)) {
50 $result = $str;
51 } else {
52 $result = $encoder->decode($str, Encode::FB_DEFAULT);
54 utf8::encode($result);
55 return $result;
58 sub git_pipe {
59 my $result = open(my $fd, "-|", "git", @_);
60 return $result ? $fd : undef;
63 sub get_git {
64 my $result;
65 my $fd = git_pipe(@_);
66 if (defined($fd)) {
67 local $/;
68 $result = <$fd>;
69 $result =~ s/(?:\r\n|[\r\n])$//;
70 close($fd);
72 return $result;
75 sub git_pipe2 {
76 my ($fdr, $fdw);
77 my $pid = open2($fdr, $fdw, "git", @_);
78 if (defined($pid)) {
79 return ($pid, $fdr, $fdw);
80 } else {
81 return undef;
85 sub git_close {
86 my $pid = shift;
87 if (defined($pid)) {
88 waitpid($pid, 0);
89 return ($? & 0x7f) ? (0x80 | ($? & 0x7f)) : (($? >> 8) & 0x7f);
90 } else {
91 return undef;
95 sub split_tagger {
96 my $g = shift;
97 defined($g) or return ();
98 my ($n, $t, $o);
99 ($g, $o) = ($1, $2) if $g =~ /^(.*?)\s*([-+]\d\d\d\d)$/;
100 ($g, $t) = ($1, 0 + $2) if $g =~ /^(.*?)\s*([-+]?\d+)$/;
101 ($n, $g) = ($1, $2), $n =~ s/\s+$// if $g =~ /^\s*([^<]*)(.*)$/;
102 $g =~ s/\s+$//;
103 $g =~ s/^<+//;
104 $g =~ s/>+$//;
105 return ($n, $g, $t, $o);
108 sub parse_tag {
109 my $to = to_utf8(shift);
110 defined($to) or return ();
111 $to =~ s/\r\n?/\n/gs;
112 $to .= "\n\n";
113 my $sep = index($to, "\n\n");
114 my @hdrs = split(/\n+/, substr($to, 0, $sep));
115 my $body = substr($to, $sep + 2);
116 my %fields = ();
117 while (my ($k, $v) = split(" ", pop(@hdrs)||'', 2)) {
118 return () unless defined($k) && defined($v) && $v ne "";
119 $fields{lc($k)} = $v;
121 exists $fields{object} && exists $fields{type} && exists $fields{tag} or
122 return ();
123 if (!exists($fields{tagger}) && $fields{type} eq "commit") {
124 # Pull up the committer as the tagger
125 # This can probably can only happen in the Git repo itself
126 my $commit = get_git("cat-file", "commit", $fields{object});
127 defined($commit) or return ();
128 $commit =~ s/\r\n?/\n/gs;
129 $commit .= "\n\n";
130 my @chdrs = split(/\n+/, substr($commit, 0, index($commit, "\n\n")));
131 while (my ($k, $v) = split(" ", pop(@chdrs)||'', 2)) {
132 next unless defined($k) && defined($v) && $v ne "";
133 $fields{tagger} = $v, last if lc($k) eq "committer";
136 exists $fields{tagger} or return ();
137 my ($n, $e, $t, $o) = split_tagger($fields{tagger});
138 $fields{name} = $n if defined($n);
139 $fields{email} = $e if defined($e);
140 $fields{seconds} = $t if defined($t);
141 if (defined($o) && $o =~ /^([-+])(\d\d)(\d\d)$/) {
142 my $sign = $1 eq "-" ? -1 : 1;
143 my $hours = 0 + $2;
144 my $mins = 0 + $3;
145 if ($hours <= 12 && $mins <= 59) {
146 $fields{offset} = $sign * ($hours * 3600 + $mins);
149 defined($fields{name}) && defined($fields{email}) &&
150 defined($fields{seconds}) && defined($fields{offset}) or
151 return ();
152 $body =~ s/(?:^|\n)-----BEGIN .*$//s;
153 $body =~ s/^\s+//s;
154 $body =~ s/\s+$//s;
155 $fields{body} = $body;
156 return %fields;
159 sub sq {
160 my $n = shift;
161 $n =~ s/\047/\047\\\047\047/gs;
162 $n =~ s/-(-+)/"\047-".("\\-" x length($1))."\047"/gse;
163 $n = "'".$n."'";
164 $n =~ s/^\047\047//s;
165 $n =~ s/\047\047$//s;
166 $n ne "" or $n = "''";
167 $n = $1 if $n =~ m{^\047([:/A-Za-z_][:/A-Za-z_0-9.-]*)\047$}s;
168 return $n;
171 sub main {
172 local *ARGV = \@_;
173 my $smudging;
174 my $name = basename($0);
175 my @optlist;
176 my $fn;
177 my $nsexit = 0;
179 Getopt::Long::Configure('bundling');
180 @optlist = (
181 'smudge' => \$smudging,
182 'no-smudge' => sub {$smudging = 0},
184 GetOptions(
185 'help|h' => sub {pod2usage(-verbose => 2, -exitval => 0)},
186 @optlist
187 ) && $#ARGV == 0 or pod2usage(-exitval => 2);
188 $fn = $ARGV[0];
189 if (!defined($smudging)) {
190 # Check for changelog.smudge setting
191 my $auto = get_git(qw(config --get changelog.smudge));
192 my $impauto = 1;
193 if (defined($auto)) {
194 $auto = lc($auto);
195 if ($truevals{$auto} || ($auto =~ /^[-+]?\d+$/ && ($auto=0+$auto))) {
196 $auto = 1;
197 $impauto = 0;
198 } else {
199 if ($auto ne "bare") {
200 $impauto = 0 if $auto eq "0";
201 $auto = 0;
202 } else {
203 $impauto = 0;
204 my $bare = get_git(qw(rev-parse --is-bare-repository));
205 if (defined($bare) && $bare eq "true") {
206 $auto = 1;
207 } else {
208 $auto = 0;
213 $smudging = 1 if $auto;
214 if (!defined($smudging)) {
215 my $pcmd = qx(ps -o comm=,args= -p @{[getppid]}) || "";
216 if ($pcmd =~ /\bgit\b.+\barchive\b/) {
217 my $imp = $impauto ? " implicit" : "";
218 warn "$name:$imp non-smudging of \"$fn\" under git archive detected!\n";
219 $nsexit = 64;
223 my $line1;
224 binmode(STDIN);
225 binmode(STDOUT);
226 if ($smudging) {
227 # Get the shebang line (or XML comment)
228 $line1 = <STDIN>;
229 defined($line1) or die "$name: missing first line\n";
230 if ($line1 =~ /^<!--/) {
231 # Avoid double smudging
232 $smudging = 0;
233 } else {
234 if ($line1 =~ m,^#!\s*/usr/bin/env\s+(?:\w+=\w*\s+)*(?:git-changelog-smudge\.pl)((?:\s.*)?)$, ||
235 $line1 =~ m,^#!\s*(?:/(?:\w+/)*)?(?:git-changelog-smudge\.pl)((?:\s.*)?)$,
237 my $sbopts = $1;
238 my $features;
239 $sbopts =~ s/^\s+//;
240 GetOptionsFromString($sbopts, @optlist,
241 'features|f=i' => \$features
242 ) or die "$name: invalid #! options: $sbopts\n";
243 # This version only understands --features=0 which means "base"
244 !$features or die "$name: invalid --features=$features option\n";
245 } else {
246 die "$name: missing #! first line\n";
250 if (!$smudging) {
251 print $line1 if defined($line1);
252 my $buf;
253 while (read(STDIN, $buf, 32768)) {
254 print $buf;
256 exit $nsexit;
258 my @tags = ();
259 while (<STDIN>) {
260 s/^\s+//;
261 s/\s+$//;
262 next if $_ eq "" || /^#/;
263 s/\s+?#.*$//;
264 my ($t, $d) = split(" ", $_ ,2);
265 defined($d) && $d ne "" or $d = $t;
266 my $f = 0;
267 $f = $1 eq "~" ? 1 : 2 if $d =~ s/^([\~\^])//;
268 push(@tags, [$t, $d, $f]);
270 my %tags = ();
271 my ($cfcmd, $cfr, $cfw) = git_pipe2(qw(cat-file --batch));
272 defined($cfcmd) or die "$name: git cat-file --batch failed\n";
273 foreach (@tags) {
274 my ($t, $d, $f) = @$_;
275 !$debug or print STDERR "Processing: $t [$f]$d\n";
276 printf $cfw "refs/tags/%s^{tag}\n", $t;
277 my (undef, $ot, $os) = split(" ", <$cfr>);
278 !$debug or print STDERR " Type: $ot Length: $os\n";
279 defined($ot) or die "$name: git cat-file --batch failed on tag: $t\n";
280 $ot eq "tag" or die "$name: no such annotated/signed tag: $t\n";
281 defined($os) && $os =~ /^\d+$/ && $os >= 64 or
282 die "$name: bad objectsize for tag \"$t\": $os\n";
283 my $tagbuf;
284 my $cnt = read($cfr, $tagbuf, $os + 1);
285 defined($cnt) && $cnt == $os + 1 or
286 die "$name: failed to read tag \"$t\" object body\n";
287 $debug < 2 or print STDERR "TAG DATA:\n$tagbuf";
288 my %tagfields = parse_tag($tagbuf);
289 defined($tagfields{body}) && defined($tagfields{seconds}) && defined($tagfields{offset}) or
290 die "$name: corrupt/invalid tag \"$t\" header fields\n";
291 BEGIN {!$debug or eval "use Data::Dumper"}
292 !$debug or print STDERR Dumper(\%tagfields);
293 print STDERR "DATE: ", strftime("%Y-%m-%d %H:%M:%S %z (%a)",
294 gmtime($tagfields{seconds} + $tagfields{offset})), "\n"
295 if $debug && defined($tagfields{seconds}) && defined($tagfields{offset});
296 my $msg = $tagfields{body};
297 my $title;
298 if ($f >= 2) {
299 $title = $d;
300 } else {
301 # Unwrap a leading "H1"
302 $msg =~ s/^(?:=+[ \t]*\n)?[ \t]*([^\n]+?)[ \t]*\n=+[ \t]*\n+/$1\n\n/s or
303 $msg =~ s/^#(?=[^#\s]|[ \t]+\S)[ \t]*([^\n]+?)[ \t]*\n+/$1\n\n/s;
304 $msg .= "\n\n";
305 my $tlen = index($msg, "\n\n");
306 if ($f) {
307 $title = $d;
308 } else {
309 $title = substr($msg, 0, $tlen);
310 $title =~ s/[ \t]*\n+[ \t]*/ /gs;
311 $title =~ s/\s+$//;
312 # Add tag prefix if missing
313 $title = $d . " - " . $title unless $title =~ /\Q$d\E/i;
314 $title =~ s/(?<!\s)[.:;,]$// if length($title) > 1;
316 $msg = substr($msg, $tlen + 2);
317 $msg =~ s/\s+$//s;
319 # Add blank lines after shortlog headers unless ``` is found
320 $msg =~ s/(?:(?<=\n)|\A)(\S[^\n]*? \([1-9]\d*\):\n) /$1\n /gs unless
321 $msg =~ m"(?:(?<=\n)|\A)\`\`\`+[ \t]*(?:[\w.+-]+[ \t]*)?\n"s;
322 $msg = "\n" . $msg unless $msg eq "" || $msg =~ /^\n/s;
323 $msg .= "\n" unless $msg eq "" || $msg =~ /\n$/s;
324 my $td = strftime("%Y-%m-%d", gmtime($tagfields{seconds} + $tagfields{offset}));
325 $tags{$t} = $title . "\n" . ("=" x length($title)) . "\n" .
326 $td . "\n" . ("-" x length($td)) . "\n" .
327 $msg;
328 !$debug or print STDERR $tags{$t};
330 print "<!-- git-changelog-smudge.pl -\\-smudge -\\- ", sq($fn), " -->\n";
331 print "\n" unless !@tags;
332 print join("\n", map("\n$tags{$$_[0]}", @tags));
333 exit 0;
336 __END__
338 =head1 NAME
340 git-changelog-smudge.pl - Smudge a ChangeLog file to include tag contents
342 =head1 SYNOPSIS
344 git-changelog-smudge.pl [options] <name-of-smudgee>
346 Options:
347 -h | --help detailed instructions
348 --smudge perform a smudge instead of a cat
349 --no-smudge perform a cat (default)
351 Git Config:
352 changelog.smudge "true" enables --smudge by default
354 =head1 OPTIONS
356 =over 8
358 =item B<--help>
360 Print the full description of git-changelog-smudge.pl's options.
362 =item B<--smudge>
364 Actually perform the ChangeLog smudge operation on the input. See the
365 L</CHANGELOG SMUDGING> section below. Without this option the input is simply
366 copied to the output without change. Overrides a previous --no-smudge option.
368 =item B<--no-smudge>
370 Disable any smudge operation and always copy the input to the output.
371 Overrides a previous --smudge option.
373 =back
375 =head1 GIT CONFIG
377 =over 8
379 =item B<changelog.smudge>
381 If the git config option C<changelog.smudge> is set to a "true" boolean value
382 then the default if neither --smudge nor --no-smudge is given is to do a
383 --smudge instead of --no-smudge. If it's set to the special value "bare" then
384 --smudge will only become the default in a bare repository (provided,
385 of course, no explicit --smudge or --no-smudge options are present).
387 =back
389 =head1 DESCRIPTION
391 git-changelog-smudge.pl provides a mechanism to translate a simple format
392 "ChangeLog" file into one containing the contents of zero or more Git tag
393 comment bodies.
395 An attempt is made to make the output Markdown compatible so that it can
396 be formatted very nicely as an HTML document for viewing.
398 The intent is that this "smudge" filter can be activated when creating an
399 archive (via C<git archive>) to expand the "ChangeLog" at that time so it's
400 included fully-expanded in the resulting archive while being maintained in
401 the working tree and repository in a non-expanded format.
403 =head1 CHANGELOG SMUDGING
405 The git-changelog-smudge.pl utility should be used as a Git "smudge" filter
406 to replace the contents of a "ChangeLog" file that contains only blank lines,
407 comments and Git tag names with a Markdown-compatible result that expands each
408 of the tag names to their entire, possibly multiline, tag message.
410 The expansion occurs as a replacement so that final output will be ordered in
411 the same order as the tag names are listed in the original "ChangeLog"
412 document.
414 Since "git shortlog" output may commonly be included, an attempt is made to
415 detect such usage and insert the needed blank line after each author name
416 and count so that the description line(s) end up being recognized as
417 preformatted text by Markdown. Note that if any 3-backticks-delimiter lines
418 are found at all this automagical blank line insertion will be disabled.
420 Signature data (if present) gets stripped from the end of the message.
422 A Markdown-style "H1" line will be added unless the beginning of the tag
423 message already contains one in which case it will have the tagname
424 (or display tag name if present) prefixed to it unless it already contains
425 (case-insensitively) the tag name (display tag name if given).
427 However, if a display tag name starting with "^" is used it will always become
428 its own separate leading "H1" line.
430 If a display tag name starting with "~" is used it will I<entirely replace>
431 whatever line would have become the "H1" line.
433 B<Syntax>
435 The first line of the file must be a shebang style comment line in this form:
437 #!/usr/bin/env git-changelog-smudge.pl <optional> <options>
439 Or alternatively this form:
441 #!/any/path/to/git-changelog-smudge.pl <optional> <options>
443 Or even this form (which is really just a special case of the previous one):
445 #!git-changelog-smudge.pl <optional> <options>
447 where optional whitespace may also be included immediately following the "!" if
448 desired.
450 All <optional> <options> will be automatically picked up and appended to the
451 list of command line options when smudging. This means they can override
452 command line options. If unrecognized options are present an error will
453 result. This provides a means to guarantee that archives generated using
454 "git-changelog-smudge.pl" smudging produce identical results even when
455 additional formatting options (if there ever are any) are used. Note that
456 adding an explicit "--no-smudge" option to the shebang line will always prevent
457 smudging from taking place!
459 Each following line of the input file is either a blank line (zero or more
460 whitespace characters), a comment line (first non-whitespace character is C<#>)
461 or a tag name line.
463 Tag name lines consist of optional leading whitespace, a valid, case-sensitive,
464 Git tag name optionally followed by a display name followed by optional
465 whitespace and comment (the whitespace is required if a comment is present)
466 like this:
468 tagname[ [^|~]display name][ #comment]
470 Here is an example "ChangeLog" file:
472 # ChangeLog for project foo
474 # More recent releases
475 v2.2.0 ~Version 2.2
476 v2.1.0 ~Version 2.1
477 v2.0.2 ~Version 2.0.2
478 v2.0.1 ~Version 2.0.1
479 v2.0.0 ~Version 2.0 # new world order
481 # only include last release of each older series
482 v1.9.3 # last of the old world
483 v1.8.7
484 v1.7.3
485 v1.6.1
486 v1.5.3 ^Broken 1.5.3 -- do not use # yikes!
488 In this example there are ten tag names that will be expanded. They just all
489 happen to start with "v". Six of them have alternate display names. Two of
490 them have comments on the tag name line and the last one has an exclusive
491 display name (as well as a comment) and the first five have alternate display
492 names. See the above L</CHANGELOG SMUDGING> section for processing details.
494 Here's another valid example "ChangeLog" file:
496 123 funny name
497 funny-name-tag # yup
498 #comments can be here too
499 # more comments
500 with-#char gotcha
502 This example contains three tag names, "123", "funny-name-tag" and "with-#char".
503 Yup, "#" is a valid tag name character as far as Git is concerned. The astute
504 reader will have noticed that the third and fourth lines were taken as comment
505 lines rather than tag lines. Currently C<git-changelog-smudge.pl> does not
506 support tag names beginning with "#" as there is no way to "escape" such names
507 from being treated as comment lines. Internal "#" characters in tag names work
508 just fine (as the example demonstrates).
510 If any invalid or nonexistent tags are detected during "ChangeLog" smudging,
511 an error message will be spit out to STDERR and a non-zero status code will
512 result. Setting the "required" filter option to "true" will cause any Git
513 smudge operations to fail in that case (the error is reported either way).
515 B<Testing>
517 To test the output simply feed the "ChangeLog" to C<git-changelog-smudge.pl> on
518 its STDIN with the C<--smudge> option like so:
520 git-changelog-smudge.pl --smudge ChangeLog < ChangeLog
522 (Adjust the filename if the "ChangeLog" is not actually stored in "ChangeLog".)
524 B<Git Configuration>
526 Two configuration items are required to use "git-changelog-smudge.pl" as a
527 "smudge" filter:
529 =over 8
531 =over 8
533 =item 1. A "ChangeLog" filter configuration
535 =item 2. An "attributes" filter assignment
537 =back
539 =back
541 The filter configuration portion may be added to the repository-local config,
542 the global config or even the system config.
544 The lines should look something like this:
546 [filter "changelog"]
547 smudge = git-changelog-smudge.pl %f
548 clean = cat
549 required
551 The following Git commands will set up the repository's local config:
553 git config filter.changelog.smudge "git-changelog-smudge.pl %f"
554 git config filter.changelog.clean cat
555 git config filter.changelog.required true
557 This variation will set up the global config:
559 git config --global filter.changelog.smudge "git-changelog-smudge.pl %f"
560 git config --global filter.changelog.clean cat
561 git config --global filter.changelog.required true
563 You don't need the repository-local version if you have the global config
564 version (but it's harmless to have both). The global makes sense if the
565 "ChangeLog" filter will be used in multiple repositories, the local config
566 if its use will be limited to one (or just a handful).
568 Once the "changelog" filter has been defined, the following line (or similar)
569 needs to be added to one of the "gitattributes" files:
571 /ChangeLog.md filter=changelog
573 See the C<git help attributes> information for more details on attributes file
574 formats. Of note here is that by starting the pattern with "/" the specified
575 file name ("ChangeLog.md" in this case) will only match at the top-level.
577 Here the extension C<.md> is used to reflect the fact that the smudged output
578 is intended to be Markdown compatible. Since the source file to be "smudged"
579 must always be named exactly as shown (if no wildcards are being used) it must
580 be named exactly "ChangeLog.md" in this case; multiple files can have the
581 "changelog" filter attribute set on them; wildcard patterns can even be used to
582 match multiple files (simply omitting the leading "/" will match the specified
583 filename in any subdirectory as well).
585 What's important is that the name given with the "filter=" attribute (in this
586 case "changelog") matches I<exactly> (it I<is> case sensitive) the name used in
587 the git config file section. The actual name used does not matter as long as
588 it's the same in both places.
590 Note that yes, Virginia, there I<are> big bad global attributes!
592 This fact may not be immediately apparent from the Git attributes
593 documentation, but the following will display the full pathname of the default
594 "global" Git attributes file (which may not actually exist):
596 sh -c 'echo "${XDG_CONFIG_HOME:-$HOME/.config}/git/attributes"'
598 There's also a C<core.attributesfile> setting that may be used to I<replace>
599 the default location of the global attributes file. In other words, if the
600 C<core.attributesfile> value is set, the pathname shown by the above line of
601 shell code will be I<ignored> (unless, of course, C<core.attributesfile> just
602 happens to be set to the same value it outputs).
604 Since the C<core.attributesfile> value can be set in a local repository
605 configuration file or even using the command line
606 S<C<-c core.attributesfile=pathname>> option, the actual location of the
607 "global" attributes file can vary on a repository-by-repository (or even
608 command-to-command) basis.
610 The repository-local attributes configuration is available either checked
611 in to the repository as a C<.gitattributes> file or local to a specific copy
612 of the repository in its C<$GIT_DIR/info/attributes> file.
614 For smudging purposes, it does not matter which "attributes" file location is
615 chosen, but at least one of them must assign the "ChangeLog" filter to at
616 least one file in order to make use of it.
618 There are pros and cons to each choice of location, but if others are expected
619 to be using the "ChangeLog" smudger on one or more files in the repository it
620 makes sense for the filter assignment to be listed in a C<.gitattributes> file
621 checked in to the repository. If use by others is not a concern then using a
622 global attributes configuration only makes sense if more than one repository
623 will be "smudging" and they will all have their "ChangeLog" files in the same
624 relative location using the same name(s). Otherwise a repository-local
625 attributes configuration (C<$GIT_DIR/info/attributes>) makes the most sense.
627 B<Activating the Smudger>
629 The above configuration will not actually smudge anything!
631 That is intentional because "git-changelog-smudge.pl" only knows how to
632 "smudge," it doesn't know how to "clean" (but it will try to notice if it's
633 already "smudged" and then just copy that to the output in case Git gets
634 carried away and tries to double-smudge something).
636 As the "git-changelog-smudge.pl" filter's primary purpose is to be used with
637 the "git archive" command, the recommended way to activate the "smudger" (once
638 the required configuration mentioned above has been completed) is like this:
640 git -c changelog.smudge=true archive <other> <arguments>
642 If the "upload-archive" facility has been enabled there's no simple way to
643 enable "git-changelog-smudge.pl" smudging for remote client archive generation
644 without also enabling it for local archive generation.
646 However, it is possible to automatically enable it for only bare repositories
647 by setting the "changelog.smudge" config variable to "bare" instead of a
648 boolean. If remote clients are always served from "bare" repositories (and
649 with the advent of the "git worktree" command in Git 2.5 that's really no added
650 space burden anymore) that should suffice. Provided, of course, that allowing
651 a remote client to cause "git-changelog-smudge.pl" to run at all is acceptable
652 in the first place.
654 =head1 LICENSE
656 =over
658 =item git-changelog-smudge.pl version 1.0.1
660 =item Copyright (C) 2017,2019,2021 Kyle J. McKay.
662 =item All rights reserved.
664 =item License GPLv2: GNU GPL version 2 only.
666 =item L<https://www.gnu.org/licenses/gpl-2.0.html>
668 =item This is free software: you are free to change and redistribute it.
670 =item There is NO WARRANTY, to the extent permitted by law.
672 =back
674 =head1 SEE ALSO
676 =over 8
678 =item B<Markdown>
680 A suitable formatter for Markdown (along with syntax descriptions etc.) can
681 be found at:
683 =over 8
685 L<https://repo.or.cz/markdown.git>
687 =back
689 =back
691 =head1 AUTHOR
693 Kyle J. McKay
695 =cut