Documentation: stop depending on Perl to generate command list

The "cmd-list.perl" script is used to extract the list of commands part of a specific category and extracts the description of each command from its respective manpage. The generated output is then included in git(1) to list all Git commands. The script is written in Perl. Refactor it to use shell scripting exclusively so that we can get rid of the mandatory dependency on Perl to build our documentation. The converted script is slower compared to its Perl implementation. But by being careful and not spawning external commands in `format_one ()` we can mitigate the performance hit to a reasonable level: Benchmark 1: Perl Time (mean ± σ): 10.3 ms ± 0.2 ms [User: 7.0 ms, System: 3.3 ms] Range (min … max): 10.0 ms … 11.1 ms 200 runs Benchmark 2: Shell Time (mean ± σ): 74.4 ms ± 0.4 ms [User: 48.6 ms, System: 24.7 ms] Range (min … max): 73.1 ms … 75.5 ms 200 runs Summary Perl ran 7.23 ± 0.13 times faster than Shell While a sevenfold slowdown is significant, the benefit of not requiring Perl for a fully-functioning Git installation outweighs waiting a couple of milliseconds longer during the build process. Signed-off-by: Patrick Steinhardt <ps@pks.im> Signed-off-by: Junio C Hamano <gitster@pobox.com>

Patrick Steinhardt committed Apr 16, 2025 at 14:16 UTC a7fa5b2f0ccb567a5a6afedece113f207902fa6f
5 files changed +109 -85
Documentation/Makefile
+2 -2
@@ -317,8 +317,8 @@ cmds_txt = cmds-ancillaryinterrogators.adoc \
317
318 $(cmds_txt): cmd-list.made
319
320 -cmd-list.made: cmd-list.perl ../command-list.txt $(MAN1_TXT)
321 - $(QUIET_GEN)$(PERL_PATH) ./cmd-list.perl .. . $(cmds_txt) && \
320 +cmd-list.made: cmd-list.sh ../command-list.txt $(MAN1_TXT)
321 + $(QUIET_GEN)$(SHELL_PATH) ./cmd-list.sh .. . $(cmds_txt) && \
322 date >$@
323
324 mergetools-%.adoc: generate-mergetool-list.sh ../git-mergetool--lib.sh $(wildcard ../mergetools/*)
Documentation/cmd-list.perl deleted
-80
@@ -1,80 +0,0 @@
1 -#!/usr/bin/perl -w
2 -
3 -use File::Compare qw(compare);
4 -
5 -sub format_one {
6 - my ($source_dir, $out, $nameattr) = @_;
7 - my ($name, $attr) = @$nameattr;
8 - my ($path) = "$source_dir/Documentation/$name.adoc";
9 - my ($state, $description);
10 - my $mansection;
11 - $state = 0;
12 - open I, '<', "$path" or die "No such file $path.adoc";
13 - while (<I>) {
14 - if (/^(?:git|scalar)[a-z0-9-]*\(([0-9])\)$/) {
15 - $mansection = $1;
16 - next;
17 - }
18 - if (/^NAME$/) {
19 - $state = 1;
20 - next;
21 - }
22 - if ($state == 1 && /^----$/) {
23 - $state = 2;
24 - next;
25 - }
26 - next if ($state != 2);
27 - chomp;
28 - $description = $_;
29 - last;
30 - }
31 - close I;
32 - if (!defined $description) {
33 - die "No description found in $path.adoc";
34 - }
35 - if (my ($verify_name, $text) = ($description =~ /^($name) - (.*)/)) {
36 - print $out "linkgit:$name\[$mansection\]::\n\t";
37 - if ($attr =~ / deprecated /) {
38 - print $out "(deprecated) ";
39 - }
40 - print $out "$text.\n\n";
41 - }
42 - else {
43 - die "Description does not match $name: $description";
44 - }
45 -}
46 -
47 -my ($source_dir, $build_dir, @categories) = @ARGV;
48 -
49 -open IN, "<$source_dir/command-list.txt";
50 -while (<IN>) {
51 - last if /^### command list/;
52 -}
53 -
54 -my %cmds = ();
55 -for (sort <IN>) {
56 - next if /^#/;
57 -
58 - chomp;
59 - my ($name, $cat, $attr) = /^(\S+)\s+(.*?)(?:\s+(.*))?$/;
60 - $attr = '' unless defined $attr;
61 - push @{$cmds{$cat}}, [$name, " $attr "];
62 -}
63 -close IN;
64 -
65 -for my $out (@categories) {
66 - my ($cat) = $out =~ /^cmds-(.*)\.adoc$/;
67 - my ($path) = "$build_dir/$out";
68 - open O, '>', "$path+" or die "Cannot open output file $out+";
69 - for (@{$cmds{$cat}}) {
70 - format_one($source_dir, \*O, $_);
71 - }
72 - close O;
73 -
74 - if (-f "$path" && compare("$path", "$path+") == 0) {
75 - unlink "$path+";
76 - }
77 - else {
78 - rename "$path+", "$path";
79 - }
80 -}
Documentation/cmd-list.sh new
+104
@@ -0,0 +1,104 @@
1 +#!/bin/sh
2 +
3 +set -e
4 +
5 +format_one () {
6 + source_dir="$1"
7 + command="$2"
8 + attributes="$3"
9 +
10 + path="$source_dir/Documentation/$command.adoc"
11 + if ! test -f "$path"
12 + then
13 + echo >&2 "No such file $path"
14 + exit 1
15 + fi
16 +
17 + state=0
18 + while read line
19 + do
20 + case "$state" in
21 + 0)
22 + case "$line" in
23 + git*\(*\)|scalar*\(*\))
24 + mansection="${line##*\(}"
25 + mansection="${mansection%\)}"
26 + ;;
27 + NAME)
28 + state=1;;
29 + esac
30 + ;;
31 + 1)
32 + if test "$line" = "----"
33 + then
34 + state=2
35 + fi
36 + ;;
37 + 2)
38 + description="$line"
39 + break
40 + ;;
41 + esac
42 + done <"$path"
43 +
44 + if test -z "$mansection"
45 + then
46 + echo "No man section found in $path" >&2
47 + exit 1
48 + fi
49 +
50 + if test -z "$description"
51 + then
52 + echo >&2 "No description found in $path"
53 + exit 1
54 + fi
55 +
56 + case "$description" in
57 + "$command - "*)
58 + text="${description#$command - }"
59 +
60 + printf "linkgit:%s[%s]::\n\t" "$command" "$mansection"
61 + case "$attributes" in
62 + *" deprecated "*)
63 + printf "(deprecated) "
64 + ;;
65 + esac
66 + printf "$text.\n\n"
67 + ;;
68 + *)
69 + echo >&2 "Description does not match $command: $description"
70 + exit 1
71 + ;;
72 + esac
73 +}
74 +
75 +source_dir="$1"
76 +build_dir="$2"
77 +shift 2
78 +
79 +for out
80 +do
81 + category="${out#cmds-}"
82 + category="${category%.adoc}"
83 + path="$build_dir/$out"
84 +
85 + while read command command_category attributes
86 + do
87 + case "$command" in
88 + "#"*)
89 + continue;;
90 + esac
91 +
92 + case "$command_category" in
93 + "$category")
94 + format_one "$source_dir" "$command" " $attributes ";;
95 + esac
96 + done <"$source_dir/command-list.txt" >"$build_dir/$out+"
97 +
98 + if cmp "$build_dir/$out+" "$build_dir/$out" >/dev/null 2>&1
99 + then
100 + rm "$build_dir/$out+"
101 + else
102 + mv "$build_dir/$out+" "$build_dir/$out"
103 + fi
104 +done
Documentation/meson.build
+2 -2
@@ -315,12 +315,12 @@ cmd_lists = [
315
316 documentation_deps += custom_target(
317 command: [
318 - perl,
318 + shell,
319 '@INPUT@',
320 meson.project_source_root(),
321 meson.current_build_dir(),
322 ] + cmd_lists,
323 - input: 'cmd-list.perl',
323 + input: 'cmd-list.sh',
324 output: cmd_lists
325 )
326
meson.build
+1 -1
@@ -779,7 +779,7 @@ endif
779 # features. It is optional if you want to neither execute tests nor use any of
780 # these optional features.
781 perl_required = get_option('perl')
782 -if get_option('gitweb').enabled() or 'netrc' in get_option('credential_helpers') or get_option('docs') != []
782 +if get_option('gitweb').enabled() or 'netrc' in get_option('credential_helpers')
783 perl_required = true
784 endif
785