fast-import: add 'strip-if-invalid' mode to --signed-commits=<mode>

Tools like `git filter-repo`[1] use `git fast-export` and `git fast-import` to rewrite repository history. When rewriting history using one such tool though, commit signatures might become invalid because the commits they sign changed due to the changes in the repository history made by the tool between the fast-export and the fast-import steps. Note that as far as signature handling goes: * Since fast-export doesn't know what changes filter-repo may make to the stream, it can't know whether the signatures will still be valid. * Since filter-repo doesn't know what history canonicalizations fast-export performed (and it performs a few), it can't know whether the signatures will still be valid. * Therefore, fast-import is the only process in the pipeline that can know whether a specified signature remains valid. Having invalid signatures in a rewritten repository could be confusing, so users rewritting history might prefer to simply discard signatures that are invalid at the fast-import step. For example a common use case is to rewrite only "recent" history. While specifying commit ranges corresponding to "recent" commits could work, users worry about getting it wrong and want to just automatically rewrite everything, expecting older commit signatures to be untouched. To let them do that, let's add a new 'strip-if-invalid' mode to the `--signed-commits=<mode>` option of `git fast-import`. It would be interesting for the `--signed-tags=<mode>` option to have this mode too, but we leave that for a future improvement. It might also be possible for `git fast-export` to have such a mode in its `--signed-commits=<mode>` and `--signed-tags=<mode>` options, but the use cases for it are much less clear, so we also leave that for possible future improvements. For now let's just die() if 'strip-if-invalid' is passed to these options where it hasn't been implemented yet. [1]: https://github.com/newren/git-filter-repo Helped-by: Elijah Newren <newren@gmail.com> Signed-off-by: Christian Couder <chriscool@tuxfamily.org> Signed-off-by: Junio C Hamano <gitster@pobox.com>

Christian Couder committed Nov 17, 2025 at 05:34 UTC c20f112e5149d1bd0d4741c4b28a65f81318309a
6 files changed +174 -24
Documentation/git-fast-import.adoc
+20 -9
@@ -66,15 +66,26 @@ fast-import stream! This option is enabled automatically for
66 remote-helpers that use the `import` capability, as they are
67 already trusted to run their own code.
68
69 ---signed-tags=(verbatim|warn-verbatim|warn-strip|strip|abort)::
70 - Specify how to handle signed tags. Behaves in the same way
71 - as the same option in linkgit:git-fast-export[1], except that
72 - default is 'verbatim' (instead of 'abort').
73 -
74 ---signed-commits=(verbatim|warn-verbatim|warn-strip|strip|abort)::
75 - Specify how to handle signed commits. Behaves in the same way
76 - as the same option in linkgit:git-fast-export[1], except that
77 - default is 'verbatim' (instead of 'abort').
69 +`--signed-tags=(verbatim|warn-verbatim|warn-strip|strip|abort)`::
70 + Specify how to handle signed tags. Behaves in the same way as
71 + the `--signed-commits=<mode>` below, except that the
72 + `strip-if-invalid` mode is not yet supported. Like for signed
73 + commits, the default mode is `verbatim`.
74 +
75 +`--signed-commits=<mode>`::
76 + Specify how to handle signed commits. The following <mode>s
77 + are supported:
78 ++
79 +* `verbatim`, which is the default, will silently import commit
80 + signatures.
81 +* `warn-verbatim` will import them, but will display a warning.
82 +* `abort` will make this program die when encountering a signed
83 + commit.
84 +* `strip` will silently make the commits unsigned.
85 +* `warn-strip` will make them unsigned, but will display a warning.
86 +* `strip-if-invalid` will check signatures and, if they are invalid,
87 + will strip them and display a warning. The validation is performed
88 + in the same way as linkgit:git-verify-commit[1] does it.
89
90 Options for Frontends
91 ~~~~~~~~~~~~~~~~~~~~~
builtin/fast-export.c
+30 -8
@@ -797,10 +797,8 @@ static void handle_commit(struct commit *commit, struct rev_info *rev,
797 (int)(committer_end - committer), committer);
798 if (signatures.nr) {
799 switch (signed_commit_mode) {
800 - case SIGN_ABORT:
801 - die("encountered signed commit %s; use "
802 - "--signed-commits=<mode> to handle it",
803 - oid_to_hex(&commit->object.oid));
800 +
801 + /* Exporting modes */
802 case SIGN_WARN_VERBATIM:
803 warning("exporting %"PRIuMAX" signature(s) for commit %s",
804 (uintmax_t)signatures.nr, oid_to_hex(&commit->object.oid));
@@ -811,12 +809,25 @@ static void handle_commit(struct commit *commit, struct rev_info *rev,
809 print_signature(item->string, item->util);
810 }
811 break;
812 +
813 + /* Stripping modes */
814 case SIGN_WARN_STRIP:
815 warning("stripping signature(s) from commit %s",
816 oid_to_hex(&commit->object.oid));
817 /* fallthru */
818 case SIGN_STRIP:
819 break;
820 +
821 + /* Aborting modes */
822 + case SIGN_ABORT:
823 + die(_("encountered signed commit %s; use "
824 + "--signed-commits=<mode> to handle it"),
825 + oid_to_hex(&commit->object.oid));
826 + case SIGN_STRIP_IF_INVALID:
827 + die(_("'strip-if-invalid' is not a valid mode for "
828 + "git fast-export with --signed-commits=<mode>"));
829 + default:
830 + BUG("invalid signed_commit_mode value %d", signed_commit_mode);
831 }
832 string_list_clear(&signatures, 0);
833 }
@@ -934,16 +945,16 @@ static void handle_tag(const char *name, struct tag *tag)
945 size_t sig_offset = parse_signed_buffer(message, message_size);
946 if (sig_offset < message_size)
947 switch (signed_tag_mode) {
937 - case SIGN_ABORT:
938 - die("encountered signed tag %s; use "
939 - "--signed-tags=<mode> to handle it",
940 - oid_to_hex(&tag->object.oid));
948 +
949 + /* Exporting modes */
950 case SIGN_WARN_VERBATIM:
951 warning("exporting signed tag %s",
952 oid_to_hex(&tag->object.oid));
953 /* fallthru */
954 case SIGN_VERBATIM:
955 break;
956 +
957 + /* Stripping modes */
958 case SIGN_WARN_STRIP:
959 warning("stripping signature from tag %s",
960 oid_to_hex(&tag->object.oid));
@@ -951,6 +962,17 @@ static void handle_tag(const char *name, struct tag *tag)
962 case SIGN_STRIP:
963 message_size = sig_offset;
964 break;
965 +
966 + /* Aborting modes */
967 + case SIGN_ABORT:
968 + die(_("encountered signed tag %s; use "
969 + "--signed-tags=<mode> to handle it"),
970 + oid_to_hex(&tag->object.oid));
971 + case SIGN_STRIP_IF_INVALID:
972 + die(_("'strip-if-invalid' is not a valid mode for "
973 + "git fast-export with --signed-tags=<mode>"));
974 + default:
975 + BUG("invalid signed_commit_mode value %d", signed_commit_mode);
976 }
977 }
978
builtin/fast-import.c
+53 -6
@@ -2772,7 +2772,7 @@ static void add_gpgsig_to_commit(struct strbuf *commit_data,
2772 {
2773 struct string_list siglines = STRING_LIST_INIT_NODUP;
2774
2775 - if (!sig->hash_algo)
2775 + if (!sig || !sig->hash_algo)
2776 return;
2777
2778 strbuf_addstr(commit_data, header);
@@ -2827,6 +2827,45 @@ static void finalize_commit_buffer(struct strbuf *new_data,
2827 strbuf_addbuf(new_data, msg);
2828 }
2829
2830 +static void handle_strip_if_invalid(struct strbuf *new_data,
2831 + struct signature_data *sig_sha1,
2832 + struct signature_data *sig_sha256,
2833 + struct strbuf *msg)
2834 +{
2835 + struct strbuf tmp_buf = STRBUF_INIT;
2836 + struct signature_check signature_check = { 0 };
2837 + int ret;
2838 +
2839 + /* Check signature in a temporary commit buffer */
2840 + strbuf_addbuf(&tmp_buf, new_data);
2841 + finalize_commit_buffer(&tmp_buf, sig_sha1, sig_sha256, msg);
2842 + ret = verify_commit_buffer(tmp_buf.buf, tmp_buf.len, &signature_check);
2843 +
2844 + if (ret) {
2845 + const char *signer = signature_check.signer ?
2846 + signature_check.signer : _("unknown");
2847 + const char *subject;
2848 + int subject_len = find_commit_subject(msg->buf, &subject);
2849 +
2850 + if (subject_len > 100)
2851 + warning(_("stripping invalid signature for commit '%.100s...'\n"
2852 + " allegedly by %s"), subject, signer);
2853 + else if (subject_len > 0)
2854 + warning(_("stripping invalid signature for commit '%.*s'\n"
2855 + " allegedly by %s"), subject_len, subject, signer);
2856 + else
2857 + warning(_("stripping invalid signature for commit\n"
2858 + " allegedly by %s"), signer);
2859 +
2860 + finalize_commit_buffer(new_data, NULL, NULL, msg);
2861 + } else {
2862 + strbuf_swap(new_data, &tmp_buf);
2863 + }
2864 +
2865 + signature_check_clear(&signature_check);
2866 + strbuf_release(&tmp_buf);
2867 +}
2868 +
2869 static void parse_new_commit(const char *arg)
2870 {
2871 static struct strbuf msg = STRBUF_INIT;
@@ -2878,6 +2917,7 @@ static void parse_new_commit(const char *arg)
2917 warning(_("importing a commit signature verbatim"));
2918 /* fallthru */
2919 case SIGN_VERBATIM:
2920 + case SIGN_STRIP_IF_INVALID:
2921 import_one_signature(&sig_sha1, &sig_sha256, v);
2922 break;
2923
@@ -2962,7 +3002,11 @@ static void parse_new_commit(const char *arg)
3002 "encoding %s\n",
3003 encoding);
3004
2965 - finalize_commit_buffer(&new_data, &sig_sha1, &sig_sha256, &msg);
3005 + if (signed_commit_mode == SIGN_STRIP_IF_INVALID &&
3006 + (sig_sha1.hash_algo || sig_sha256.hash_algo))
3007 + handle_strip_if_invalid(&new_data, &sig_sha1, &sig_sha256, &msg);
3008 + else
3009 + finalize_commit_buffer(&new_data, &sig_sha1, &sig_sha256, &msg);
3010
3011 free(author);
3012 free(committer);
@@ -2984,9 +3028,6 @@ static void handle_tag_signature(struct strbuf *msg, const char *name)
3028 switch (signed_tag_mode) {
3029
3030 /* First, modes that don't change anything */
2987 - case SIGN_ABORT:
2988 - die(_("encountered signed tag; use "
2989 - "--signed-tags=<mode> to handle it"));
3031 case SIGN_WARN_VERBATIM:
3032 warning(_("importing a tag signature verbatim for tag '%s'"), name);
3033 /* fallthru */
@@ -3003,7 +3044,13 @@ static void handle_tag_signature(struct strbuf *msg, const char *name)
3044 strbuf_setlen(msg, sig_offset);
3045 break;
3046
3006 - /* Third, BUG */
3047 + /* Third, aborting modes */
3048 + case SIGN_ABORT:
3049 + die(_("encountered signed tag; use "
3050 + "--signed-tags=<mode> to handle it"));
3051 + case SIGN_STRIP_IF_INVALID:
3052 + die(_("'strip-if-invalid' is not a valid mode for "
3053 + "git fast-import with --signed-tags=<mode>"));
3054 default:
3055 BUG("invalid signed_tag_mode value %d from tag '%s'",
3056 signed_tag_mode, name);
gpg-interface.c
+2
@@ -1146,6 +1146,8 @@ int parse_sign_mode(const char *arg, enum sign_mode *mode)
1146 *mode = SIGN_WARN_STRIP;
1147 else if (!strcmp(arg, "strip"))
1148 *mode = SIGN_STRIP;
1149 + else if (!strcmp(arg, "strip-if-invalid"))
1150 + *mode = SIGN_STRIP_IF_INVALID;
1151 else
1152 return -1;
1153 return 0;
gpg-interface.h
+1
@@ -111,6 +111,7 @@ enum sign_mode {
111 SIGN_VERBATIM,
112 SIGN_WARN_STRIP,
113 SIGN_STRIP,
114 + SIGN_STRIP_IF_INVALID,
115 };
116
117 /*
t/t9305-fast-import-signatures.sh
+68 -1
@@ -79,7 +79,7 @@ test_expect_success GPG 'setup a commit with dual OpenPGP signatures on its SHA-
79 echo B >explicit-sha256/B &&
80 git -C explicit-sha256 add B &&
81 test_tick &&
82 - git -C explicit-sha256 commit -S -m "signed" B &&
82 + git -C explicit-sha256 commit -S -m "signed commit" B &&
83 SHA256_B=$(git -C explicit-sha256 rev-parse dual-signed) &&
84
85 # Create the corresponding SHA-1 commit
@@ -103,4 +103,71 @@ test_expect_success GPG 'strip both OpenPGP signatures with --signed-commits=war
103 test_line_count = 2 out
104 '
105
106 +test_expect_success GPG 'import commit with no signature with --signed-commits=strip-if-invalid' '
107 + git fast-export main >output &&
108 + git -C new fast-import --quiet --signed-commits=strip-if-invalid <output >log 2>&1 &&
109 + test_must_be_empty log
110 +'
111 +
112 +test_expect_success GPG 'keep valid OpenPGP signature with --signed-commits=strip-if-invalid' '
113 + rm -rf new &&
114 + git init new &&
115 +
116 + git fast-export --signed-commits=verbatim openpgp-signing >output &&
117 + git -C new fast-import --quiet --signed-commits=strip-if-invalid <output >log 2>&1 &&
118 + IMPORTED=$(git -C new rev-parse --verify refs/heads/openpgp-signing) &&
119 + test $OPENPGP_SIGNING = $IMPORTED &&
120 + git -C new cat-file commit "$IMPORTED" >actual &&
121 + test_grep -E "^gpgsig(-sha256)? " actual &&
122 + test_must_be_empty log
123 +'
124 +
125 +test_expect_success GPG 'strip signature invalidated by message change with --signed-commits=strip-if-invalid' '
126 + rm -rf new &&
127 + git init new &&
128 +
129 + git fast-export --signed-commits=verbatim openpgp-signing >output &&
130 +
131 + # Change the commit message, which invalidates the signature.
132 + # The commit message length should not change though, otherwise the
133 + # corresponding `data <length>` command would have to be changed too.
134 + sed "s/OpenPGP signed commit/OpenPGP forged commit/" output >modified &&
135 +
136 + git -C new fast-import --quiet --signed-commits=strip-if-invalid <modified >log 2>&1 &&
137 +
138 + IMPORTED=$(git -C new rev-parse --verify refs/heads/openpgp-signing) &&
139 + test $OPENPGP_SIGNING != $IMPORTED &&
140 + git -C new cat-file commit "$IMPORTED" >actual &&
141 + test_grep ! -E "^gpgsig" actual &&
142 + test_grep "stripping invalid signature" log
143 +'
144 +
145 +test_expect_success GPGSM 'keep valid X.509 signature with --signed-commits=strip-if-invalid' '
146 + rm -rf new &&
147 + git init new &&
148 +
149 + git fast-export --signed-commits=verbatim x509-signing >output &&
150 + git -C new fast-import --quiet --signed-commits=strip-if-invalid <output >log 2>&1 &&
151 + IMPORTED=$(git -C new rev-parse --verify refs/heads/x509-signing) &&
152 + test $X509_SIGNING = $IMPORTED &&
153 + git -C new cat-file commit "$IMPORTED" >actual &&
154 + test_grep -E "^gpgsig(-sha256)? " actual &&
155 + test_must_be_empty log
156 +'
157 +
158 +test_expect_success GPGSSH 'keep valid SSH signature with --signed-commits=strip-if-invalid' '
159 + rm -rf new &&
160 + git init new &&
161 +
162 + test_config -C new gpg.ssh.allowedSignersFile "${GPGSSH_ALLOWED_SIGNERS}" &&
163 +
164 + git fast-export --signed-commits=verbatim ssh-signing >output &&
165 + git -C new fast-import --quiet --signed-commits=strip-if-invalid <output >log 2>&1 &&
166 + IMPORTED=$(git -C new rev-parse --verify refs/heads/ssh-signing) &&
167 + test $SSH_SIGNING = $IMPORTED &&
168 + git -C new cat-file commit "$IMPORTED" >actual &&
169 + test_grep -E "^gpgsig(-sha256)? " actual &&
170 + test_must_be_empty log
171 +'
172 +
173 test_done