Git development
 help / color / mirror / Atom feed
From: Christian Couder <christian.couder@gmail.com>
To: git@vger.kernel.org
Cc: Junio C Hamano <gitster@pobox.com>,
	Kaartic Sivaraam <kaartic.sivaraam@gmail.com>,
	Patrick Steinhardt <ps@pks.im>, Elijah Newren <newren@gmail.com>,
	Jeff King <peff@peff.net>,
	"brian m . carlson" <sandals@crustytoothpaste.net>,
	Johannes Schindelin <Johannes.Schindelin@gmx.de>,
	Justin Tobler <jltobler@gmail.com>,
	Christian Couder <christian.couder@gmail.com>
Subject: [PATCH v3 0/4] Standardize early option scanning
Date: Mon, 12 Oct 2026 09:44:09 +0200	[thread overview]
Message-ID: <20261012074413.337183-1-christian.couder@gmail.com> (raw)
In-Reply-To: <20260923080928.1534413-1-christian.couder@gmail.com>

A number of commands perform an early scan of their arguments to look
for specific flags or structural separators (like `--`).

These hand-rolled early scans are often fragile. They especially fail
to account for options that take their value as a separate
argument. This leads to disagreements between the early scan and the
actual parse_options() pass. For example, the early scanner might miss
a special option entirely, or mistakenly treat an option's value as
the `--` path separator.

To allow these commands to safely skip option values during their
early scans, this series introduces a new "early-scan" sub-API into
the existing "parse-options" API.

This is deliberately implemented as a new simple and fast scan. A full
'dry run' of parse_options() would have to avoid writing through
`opt->value`, calling callbacks or dying. Instead, the scan reuses
only the long option lookup of parse_options(), and stops when unsure.

This allows negated options ("--no-..."), unambiguous abbreviated
options and aliases to be properly handled. The early scan also agrees
with parse_options() on which options take a value, including those
with PARSE_OPT_OPTARG or PARSE_OPT_LASTARG_DEFAULT.

To never misinterpret an option's value as an option though, it stops
at any argument it cannot reliably skip:

 1. a short option, or a bundle of them,

 2. an unknown option, or an ambiguous or disallowed abbreviation,

 3. an option with a low-level callback, as such a callback may
 consume any number of arguments,

 4. an option with a missing or stuck value that parse_options() would
 reject, like "--no-<name>=<value>".

So the scan may fail to see an option that parse_options() would
accept, but it doesn't report an option that parse_options() would
not, and callers can safely err on the side of not having seen it.

This holds as long as the scan is told how the later parse_options()
call treats its arguments: EARLY_SCAN_STOP_AT_NON_OPTION should be
passed when parse_options() stops at non-options or has subcommands,
and the scan should not be used with PARSE_OPT_KEEP_UNKNOWN_OPT, which
disables abbreviations in parse_options() only.

The early scan is performed by a new early_scan_options() function
which takes a regular `const struct option *option` array as
argument.

This requires that the command already uses `struct option` and the
parse-options API to parse its arguments. As the majority of commands
performing an early scan don't use the parse-options API yet, they
will have to be converted to use it before they can use
early_scan_options().

In this series, only `git fast-import` is converted to the early-scan
API. Its hand-rolled scan disagreed with parse_options() in both
directions:

  - `git fast-import --depth 5 --allow-unsafe-features` silently
    ignored `--allow-unsafe-features`, refusing unsafe features from
    the stream, and so did `--allow-unsafe`, its abbreviation,

  - `git fast-import --quiet --export-pack --allow-unsafe-features`
    and `git fast-import --end-of-options --allow-unsafe-features`
    allowed unsafe features from the stream, while parse_options()
    takes `--allow-unsafe-features` as the value of
    `--export-pack-edges` in the first case, and as a non-option
    argument in the second one.

All these cases now agree with parse_options().

Overview of the patches
=======================

 - Patch 1/4 refactors some existing code into a new
   parse_options_takes_argument() helper that will be used in patch
   3/4.

 - Patch 2/4 is new in this v3. It extracts the long option lookup
   code from parse_long_opt() into a new lookup_long_opt() function,
   so that the function can be reused in the next patch.

 - Patch 3/4 introduces early_scan_options(), the early scanner that
   will be used instead of hand-rolled ones, along with the
   PARSE_OPT_EARLY flag and tests.

 - Patch 4/4 uses early_scan_options() to fix the early scan for
   `--allow-unsafe-features` in `git fast-import`.

Changes since v2
================

Thanks a lot to Junio and Kaartic who reviewed previous versions.

Rebased on top of a018953688 (Git 2.56, 2026-09-27) to start from a
stable base.

There are a number of important changes since v2:

 - In patch 1/4, parse_options_takes_argument() now considers
   PARSE_OPT_LASTARG_DEFAULT options as taking a value. This allows us
   to remove a limitation that was in v2 as these options now consume
   the next argument when there is one, like in parse_options().

 - Patch 2/4 is new, see above.

 - In patch 3/4, we reuse the new lookup_long_opt() function extracted
   by patch 2/4. This removes a number of limitations (around negated
   forms, unambiguous abbreviations and aliases) that were in v2, as
   the early scan now behaves in a similar way as parse_options()
   regarding long options. A number of tests have been added to ensure
   that.

 - In patch 3/4, the scan now stops at short options, unknown options,
   ambiguous or disallowed abbreviations, options with a low-level
   callback, and value-taking options given as the last argument,
   instead of skipping over them, as it could otherwise take an
   option's value for an option.

 - In patch 3/4, early_scan_fn() has a new `unset` argument for
   negated options, and early_scan_options() now returns the index of
   the first argument not consumed also when the callback stops the
   scan.

 - In patch 4/4, the commit message and documentation are updated to
   account for the better behavior of the early scan. A related
   NEEDSWORK comment is also removed, and some tests added.

CI tests:
=========

They all pass, see:

https://github.com/chriscool/git/actions/runs/37965635739

Range-diff since v2
===================

1:  32adff46ce ! 1:  57c48ab0fe parse-options: add parse_options_takes_argument()
    @@ Commit message
         function, and let's use it in show_gitcomp().
     
         Note that an option with PARSE_OPT_LASTARG_DEFAULT only consumes the
    -    next argument when it isn't the last one, so it is not considered as
    -    taking a value, which is what show_gitcomp() already did.
    +    next argument when it isn't the last one. It is considered as taking a
    +    value by parse_options_takes_argument(), as an early scan can easily
    +    check whether there is a next argument, and would otherwise wrongly
    +    take that argument for an option. show_gitcomp() keeps not appending
    +    an '=' to such an option though, as it already did, since its value is
    +    optional.
     
         Signed-off-by: Christian Couder <christian.couder@gmail.com>
     
    @@ parse-options.c: static void show_negated_gitcomp(const struct option *opts, int
     +		return 0;
     +	}
     +
    -+	if (opt->flags & (PARSE_OPT_NOARG | PARSE_OPT_OPTARG |
    -+			  PARSE_OPT_LASTARG_DEFAULT))
    ++	if (opt->flags & (PARSE_OPT_NOARG | PARSE_OPT_OPTARG))
     +		return 0;
     +
     +	return 1;
    @@ parse-options.c: static int show_gitcomp(const struct option *opts, int show_all
     -			suffix = "=";
     -			break;
      		default:
    -+			if (parse_options_takes_argument(opts))
    ++			if (parse_options_takes_argument(opts) &&
    ++			    !(opts->flags & PARSE_OPT_LASTARG_DEFAULT))
     +				suffix = "=";
      			break;
      		}
    @@ parse-options.h: int parse_options(int argc, const char **argv, const char *pref
     + * Return non-zero if `opt` takes a value, which means that it consumes
     + * the next argument when that value is not stuck to it with an '='.
     + *
    -+ * Note that an option with PARSE_OPT_LASTARG_DEFAULT only consumes the
    -+ * next argument when it isn't the last one, so it is not considered as
    -+ * taking a value here.
    ++ * Note that an option with PARSE_OPT_LASTARG_DEFAULT is considered as
    ++ * taking a value here, even though it only consumes the next argument
    ++ * when it isn't the last one. Callers that care have to check for that
    ++ * flag themselves.
     + */
     +int parse_options_takes_argument(const struct option *opt);
     +
-:  ---------- > 2:  38243ce8bf parse-options: extract lookup_long_opt() from parse_long_opt()
2:  82569fa602 ! 3:  c36cae4e44 parse-options: add early_scan_options()
    @@ Commit message
     
         Let's add early_scan_options() to help with this. It walks the
         arguments using the very same `struct option` array that the command
    -    already passes to parse_options(), and uses the
    -    parse_options_takes_argument() helper added in a previous commit, so
    -    that the scan and the actual parsing agree on which options take a
    -    value.
    +    already passes to parse_options(). It looks up long options with the
    +    lookup_long_opt() helper extracted from parse_long_opt() in a previous
    +    commit, so that negated forms, unambiguous abbreviations and aliases
    +    are recognized the same way, and it uses the
    +    parse_options_takes_argument() helper added in another previous
    +    commit, so that the scan and the actual parsing agree on which options
    +    take a value.
     
         The options the caller wants to be told about are marked with a new
         PARSE_OPT_EARLY flag, so that nothing has to be spelled out a second
    @@ Commit message
         the commands converted now would keep both their option array and their
         PARSE_OPT_EARLY marks, so their conversion would not have to be redone.
     
    -    One consequence of staying simple is that abbreviated options are
    -    still not matched, even though the scan is now given the command's
    -    full option array. Resolving them the way parse_options() does would
    -    mean duplicating the ambiguity detection that parse_long_opt()
    -    performs. So the scan can fail to see an option that parse_options()
    -    would accept, and its callers have to cope with that, typically by
    -    erring on the safe side. This and the other differences with
    -    parse_options() are documented in "parse-options.h".
    +    One consequence of staying simple is that the scan doesn't try to
    +    handle what it cannot reliably skip, like short options, unknown
    +    options, ambiguous abbreviations or options with a low-level callback,
    +    which may consume any number of arguments. It stops at them instead,
    +    so that it never takes an option's value for an option. This matters,
    +    as for example with:
    +
    +      git fast-import --quiet --export-pack --allow-unsafe-features
    +
    +    parse_options() takes `--allow-unsafe-features` as the value of
    +    `--export-pack-edges`, which `--export-pack` abbreviates. A scan that
    +    just skipped `--export-pack` as an unknown option would wrongly see
    +    `--allow-unsafe-features` there.
    +
    +    So the scan can fail to see an option that parse_options() would
    +    accept, but it doesn't report an option that parse_options() would
    +    not, and its callers can safely err on the side of not having seen an
    +    option. The differences with parse_options() are documented in
    +    "parse-options.h".
     
         In practice, despite these limitations, early scans using
         early_scan_options() should still be safer and cleaner than the
    @@ Commit message
         about option values at all and therefore disagree with
         parse_options() in ways that create plain bugs.
     
    +    Helped-by: Kaartic Sivaraam <kaartic.sivaraam@gmail.com>
         Signed-off-by: Christian Couder <christian.couder@gmail.com>
     
      ## Documentation/technical/api-parse-options.adoc ##
    @@ parse-options.c: int parse_options(int argc, const char **argv,
      }
      
     +/*
    -+ * Look for `arg` among `option`. On success, return the matching option
    -+ * and set `value` to the value stuck to it, if any, or to NULL.
    ++ * Look for `name`, a long option without its leading "--", among
    ++ * `option`, the way parse_options() would. Return true if it names an
    ++ * option exactly, or as an unambiguous abbreviation that is not
    ++ * disallowed, with `found` set to that option and its flags, and
    ++ * `stuck` to the value stuck to it, if any, or to NULL.
    ++ */
    ++static bool find_early_scan_option(struct parse_opt_ctx_t *p,
    ++				   const char *name,
    ++				   const struct option *option,
    ++				   bool disallow_abbrev,
    ++				   struct parsed_option *found,
    ++				   const char **stuck)
    ++{
    ++	struct parsed_option other;
    ++	enum long_opt_lookup result = lookup_long_opt(p, name, option,
    ++						      found, &other, stuck);
    ++
    ++	switch (result) {
    ++	case LONG_OPT_EXACT:
    ++		return true;
    ++	case LONG_OPT_ABBREV:
    ++		return !disallow_abbrev;
    ++	case LONG_OPT_AMBIGUOUS:
    ++	case LONG_OPT_UNKNOWN:
    ++		return false;
    ++	}
    ++
    ++	BUG("unknown enum long_opt_lookup value '%d'", result);
    ++}
    ++
    ++struct early_scan_state {
    ++	struct parse_opt_ctx_t ctx;
    ++	const struct option *option;
    ++	enum early_scan_flags flags;
    ++	early_scan_fn *fn;
    ++	void *data;
    ++	bool disallow_abbrev;
    ++};
    ++
    ++/*
    ++ * Look at argv[*i]. Return 'true' to go on scanning, with *i on the
    ++ * last argument consumed. Return 'false' to stop, with *i set to the
    ++ * first argument not consumed.
     + */
    -+static const struct option *find_early_scan_option(const char *arg,
    -+						   const struct option *option,
    -+						   const char **value)
    ++static bool early_scan_one_option(struct early_scan_state *s,
    ++				  int argc, const char **argv, int *i)
     +{
    -+	if (!skip_prefix(arg, "--", &arg))
    -+		return NULL;
    -+
    -+	for (const struct option *opt = option; opt->type != OPTION_END; opt++) {
    -+		const char *rest;
    -+
    -+		if (opt->type == OPTION_SUBCOMMAND)
    -+			continue;
    -+		if (!opt->long_name)
    -+			continue;
    -+		if (!skip_prefix(arg, opt->long_name, &rest))
    -+			continue;
    -+
    -+		if (!*rest) {
    -+			*value = NULL;
    -+			return opt;
    -+		}
    -+		/* Only an option that can take a value may have one stuck to it. */
    -+		if (*rest == '=' && !(opt->flags & PARSE_OPT_NOARG)) {
    -+			*value = rest + 1;
    -+			return opt;
    -+		}
    ++	const char *name;
    ++	const char *value;
    ++	struct parsed_option found;
    ++	bool unset;
    ++	int pos = *i;
    ++	const char *arg = argv[pos];
    ++
    ++	/*
    ++	 * parse_options() always stops parsing options at these,
    ++	 * whatever its flags, so nothing after them is an option.
    ++	 */
    ++	if (!strcmp(arg, "--") || !strcmp(arg, "--end-of-options"))
    ++		return false;
    ++
    ++	/* non-option */
    ++	if (*arg != '-' || !arg[1])
    ++		return !(s->flags & EARLY_SCAN_STOP_AT_NON_OPTION);
    ++
    ++	/* short option */
    ++	if (!skip_prefix(arg, "--", &name))
    ++		return false;
    ++
    ++	/* unknown, ambiguous or disallowed abbreviation */
    ++	if (!find_early_scan_option(&s->ctx, name, s->option,
    ++				    s->disallow_abbrev, &found, &value))
    ++		return false;
    ++
    ++	/* A low-level callback may consume any number of arguments. */
    ++	if (found.option->ll_callback)
    ++		return false;
    ++
    ++	unset = !!(found.flags & OPT_UNSET);
    ++
    ++	/* parse_options() rejects these, so don't go further. */
    ++	if (value && (unset || (found.option->flags & PARSE_OPT_NOARG)))
    ++		return false;
    ++
    ++	/*
    ++	 * Like get_arg(), take the next argument as the value when it is
    ++	 * not stuck to the option. When there is no next argument,
    ++	 * parse_options() uses the default value of an option with
    ++	 * PARSE_OPT_LASTARG_DEFAULT, but errors out for other options.
    ++	 */
    ++	if (!value && !unset && parse_options_takes_argument(found.option)) {
    ++		if (pos + 1 < argc)
    ++			value = argv[++*i];
    ++		else if (!(found.option->flags & PARSE_OPT_LASTARG_DEFAULT))
    ++			return false;
    ++	}
    ++
    ++	if ((found.option->flags & PARSE_OPT_EARLY) &&
    ++	    s->fn(found.option, value, unset, pos, s->data)) {
    ++		/* the option, and its value if any, have been consumed */
    ++		++*i;
    ++		return false;
     +	}
     +
    -+	return NULL;
    ++	return true;
     +}
     +
     +int early_scan_options(int argc, const char **argv,
    @@ parse-options.c: int parse_options(int argc, const char **argv,
     +		       enum early_scan_flags flags,
     +		       early_scan_fn *fn, void *data)
     +{
    -+	for (int i = 0; i < argc; i++) {
    -+		const char *arg = argv[i];
    -+		const char *value;
    -+		const struct option *opt;
    -+		int pos = i;
    -+
    -+		/*
    -+		 * parse_options() always stops parsing options at these,
    -+		 * whatever its flags, so nothing after them is an option.
    -+		 */
    -+		if (!strcmp(arg, "--") || !strcmp(arg, "--end-of-options"))
    -+			return i;
    -+
    -+		opt = find_early_scan_option(arg, option, &value);
    -+		if (!opt) {
    -+			if ((flags & EARLY_SCAN_STOP_AT_NON_OPTION) &&
    -+			    (*arg != '-' || !arg[1]))
    -+				return i;
    -+			continue;
    -+		}
    -+
    -+		/*
    -+		 * When an option takes a value, but that value is not
    -+		 * stuck to it with '=', then the next argument is the
    -+		 * value and it has to be skipped so that it isn't
    -+		 * taken for an option itself.
    -+		 */
    -+		if (parse_options_takes_argument(opt) && !value && i + 1 < argc)
    -+			value = argv[++i];
    -+
    -+		if (opt->flags & PARSE_OPT_EARLY && fn(opt, value, pos, data))
    -+			return i;
    -+	}
    ++	struct early_scan_state s = {
    ++		.flags = flags,
    ++		.fn = fn,
    ++		.data = data,
    ++		.disallow_abbrev =
    ++			git_env_bool("GIT_TEST_DISALLOW_ABBREVIATED_OPTIONS", 0),
    ++	};
    ++	struct option *real_option = preprocess_options(&s.ctx, option);
    ++	int i;
    ++
    ++	s.option = real_option ? real_option : option;
    ++
    ++	for (i = 0; i < argc; i++)
    ++		if (!early_scan_one_option(&s, argc, argv, &i))
    ++			break;
     +
    -+	return argc;
    ++	free_preprocessed_options(real_option);
    ++	free(s.ctx.alias_groups);
    ++	return i;
     +}
     +
      static int usage_argh(const struct option *opts, FILE *outfile)
    @@ parse-options.h: static inline void die_for_incompatible_opt2(int opt1, const ch
     + * Called by early_scan_options() for each argument matching a
     + * `struct option` with PARSE_OPT_EARLY set.
     + *
    -+ * `option` is the matching option, `value` its value or NULL if it
    -+ * doesn't take one, and `pos` the index of the option in argv.
    ++ * - `option` is the matching option,
    ++ * - `value` its value, or NULL if it doesn't take one or if its value
    ++ *   is optional and wasn't given, as with PARSE_OPT_OPTARG, or with
    ++ *   PARSE_OPT_LASTARG_DEFAULT when the option is the last argument,
    ++ * - `unset` is 'true' for the negated form of the option (like
    ++ *   for parse_opt_cb()), in which case `value` is always NULL, and
    ++ * - `pos` the index of the option in argv.
     + *
    -+ * Returning a non-zero value stops the scan.
    ++ * Returning a non-zero value stops the scan right after the option and
    ++ * its value, if any.
     + */
     +typedef int early_scan_fn(const struct option *option, const char *value,
    -+			  int pos, void *data);
    ++			  bool unset, int pos, void *data);
     +
     +enum early_scan_flags {
     +	EARLY_SCAN_STOP_AT_NON_OPTION = 1 << 0, /* Stop at any non option */
    @@ parse-options.h: static inline void die_for_incompatible_opt2(int opt1, const ch
     + * flags. PARSE_OPT_KEEP_DASHDASH and PARSE_OPT_KEEP_UNKNOWN_OPT only
     + * decide if the terminator is left in argv, not if it terminates.
     + *
    -+ * Returns the index at which the scan stopped, which is `argc` when the
    -+ * whole array was scanned.
    ++ * Returns the index of the first argument not consumed by the scan,
    ++ * which is `argc` when the whole array was scanned.
    ++ *
    ++ * Long options are looked up the same way as parse_options() does,
    ++ * so their negated forms, their unambiguous abbreviations, unless
    ++ * GIT_TEST_DISALLOW_ABBREVIATED_OPTIONS is set, and the OPTION_ALIAS
    ++ * entries standing for them are all recognized, and their value is
    ++ * skipped when they take one.
    ++ *
    ++ * To never misinterpret an option value as an option though, the scan
    ++ * stops at any argument it cannot reliably skip, that is:
     + *
    -+ * This scan is for now deliberately much simpler than
    -+ * parse_options(), so it differs from it in the following ways:
    ++ *  - a short option, or a bundle of them,
     + *
    -+ *  - Only the long form of an option is matched, and it has to be
    -+ *    spelled in full: short options and abbreviations are ignored.
    ++ *  - an unknown option, or an ambiguous or disallowed abbreviation,
     + *
    -+ *  - Negated forms ("--no-<name>") are not matched. This is harmless,
    -+ *    as they never take a value to skip.
    ++ *  - an option with a low-level callback, as such a callback may
    ++ *    consume any number of arguments,
     + *
    -+ *  - Options with PARSE_OPT_OPTARG or PARSE_OPT_LASTARG_DEFAULT are
    -+ *    treated as not taking a separate value.
    ++ *  - an option with a value stuck to it that parse_options() would
    ++ *    reject, like a negated option or a PARSE_OPT_NOARG one,
     + *
    -+ *  - OPTION_SUBCOMMAND entries are skipped.
    ++ *  - an option requiring a value when it is the last argument, which
    ++ *    parse_options() would also reject.
     + *
    -+ *  - OPTION_ALIAS entries are not resolved to the option they stand
    -+ *    for.
    ++ * In all these cases, the scan can fail to see an option that
    ++ * parse_options() would accept, but it doesn't report an option that
    ++ * parse_options() would not, so that callers err on the safe side.
     + *
    -+ * So the scan can fail to see an option that parse_options() would
    -+ * accept, and callers have to cope with that, typically by erring on
    -+ * the safe side.
    ++ * This requires the scan to be told about the way the later
    ++ * parse_options() call treats its arguments though: callers should
    ++ * pass EARLY_SCAN_STOP_AT_NON_OPTION when parse_options() is called
    ++ * with PARSE_OPT_STOP_AT_NON_OPTION or with subcommands, and should
    ++ * not use the scan when parse_options() is called with
    ++ * PARSE_OPT_KEEP_UNKNOWN_OPT, which disables abbreviations there but
    ++ * not here.
     + */
     +int early_scan_options(int argc, const char **argv,
     +		       const struct option *option,
    @@ t/helper/test-parse-options.c: int cmd__parse_subcommand(int argc, const char **
      	return parse_subcommand__cmd(argc, argv, test_flags);
      }
     +
    ++static enum parse_opt_result early_ll_callback(struct parse_opt_ctx_t *ctx UNUSED,
    ++					       const struct option *opt UNUSED,
    ++					       const char *arg UNUSED,
    ++					       int unset UNUSED)
    ++{
    ++	return 0;
    ++}
    ++
     +static int show_early_option(const struct option *opt, const char *value,
    -+			     int pos, void *data UNUSED)
    ++			     bool unset, int pos, void *data)
     +{
    ++	int *stop_at_found = data;
    ++
     +	printf("found: %s at %d", opt->long_name, pos);
    ++	if (unset)
    ++		printf(" unset");
     +	if (value)
     +		printf(" value: %s", value);
     +	putchar('\n');
    -+	return 0;
    ++	return *stop_at_found;
     +}
     +
     +int cmd__early_scan_options(int argc, const char **argv)
    @@ t/helper/test-parse-options.c: int cmd__parse_subcommand(int argc, const char **
     +			     PARSE_OPT_EARLY),
     +		OPT_STRING(0, "skipped-value", &a_string, "str",
     +			   "option whose value has to be skipped"),
    ++		OPT_ALIAS(0, "alias-of-skipped", "skipped-value"),
     +		OPT_INTEGER(0, "number", &an_int,
     +			    "option taking an integer value"),
     +		OPT_STRING_F(0, "optarg", &a_string, "str",
    @@ t/helper/test-parse-options.c: int cmd__parse_subcommand(int argc, const char **
     +		OPT_STRING_F(0, "early-lastarg", &a_string, "str",
     +			     "early option with a last argument default",
     +			     PARSE_OPT_EARLY | PARSE_OPT_LASTARG_DEFAULT),
    ++		OPT_BOOL_F(0, "no-early", &a_bool,
    ++			   "early option whose name starts with 'no-'",
    ++			   PARSE_OPT_EARLY),
     +		OPT_BOOL('s', NULL, &a_short, "short only option"),
    ++		{
    ++			.type = OPTION_LOWLEVEL_CALLBACK,
    ++			.long_name = "low-level",
    ++			.help = "option with a low-level callback",
    ++			.flags = PARSE_OPT_NOARG | PARSE_OPT_NONEG,
    ++			.ll_callback = early_ll_callback,
    ++		},
    ++		{
    ++			.type = OPTION_CALLBACK,
    ++			.long_name = "ll-value",
    ++			.argh = "str",
    ++			.help = "option with a value and a low-level callback",
    ++			.flags = PARSE_OPT_NONEG,
    ++			.ll_callback = early_ll_callback,
    ++		},
     +		OPT_END()
     +	};
     +
     +	enum early_scan_flags flags = 0;
    ++	int stop_at_found = 0;
     +	int stopped;
     +
     +	while (argc > 1 && *argv[1] == '-') {
     +		if (!strcmp(argv[1], "--stop-at-non-option"))
     +			flags |= EARLY_SCAN_STOP_AT_NON_OPTION;
    ++		else if (!strcmp(argv[1], "--stop-at-found"))
    ++			stop_at_found = 1;
     +		else
     +			break;
     +		argc--;
    @@ t/helper/test-parse-options.c: int cmd__parse_subcommand(int argc, const char **
     +	}
     +
     +	stopped = early_scan_options(argc - 1, argv + 1, option, flags,
    -+				     show_early_option, NULL);
    ++				     show_early_option, &stop_at_found);
     +	printf("stopped at: %d of %d\n", stopped, argc - 1);
     +
     +	return 0;
    @@ t/t0040-parse-options.sh: test_expect_success 'u16 limits range' '
     +	found: wanted at 2
     +	stopped at: 3 of 3
     +	EOF
    ++	test_cmp expect actual &&
    ++	test-tool early-scan-options --number --wanted >actual &&
    ++	cat >expect <<-\EOF &&
    ++	stopped at: 2 of 2
    ++	EOF
    ++	test_cmp expect actual
    ++'
    ++
    ++test_expect_success 'early_scan_options() stops at a last option missing its value' '
    ++	test-tool early-scan-options --wanted-value >actual &&
    ++	cat >expect <<-\EOF &&
    ++	stopped at: 0 of 1
    ++	EOF
    ++	test_cmp expect actual &&
    ++	test-tool early-scan-options --wanted --skipped-value >actual &&
    ++	cat >expect <<-\EOF &&
    ++	found: wanted at 0
    ++	stopped at: 1 of 2
    ++	EOF
     +	test_cmp expect actual
     +'
     +
    @@ t/t0040-parse-options.sh: test_expect_success 'u16 limits range' '
     +	test_cmp expect actual
     +'
     +
    -+test_expect_success 'early_scan_options() ignores abbreviated options' '
    -+	test-tool early-scan-options --want >actual &&
    ++test_expect_success 'early_scan_options() matches unambiguous abbreviations' '
    ++	GIT_TEST_DISALLOW_ABBREVIATED_OPTIONS=false \
    ++	test-tool early-scan-options --wanted-v=x >actual &&
     +	cat >expect <<-\EOF &&
    ++	found: wanted-value at 0 value: x
     +	stopped at: 1 of 1
     +	EOF
    ++	test_cmp expect actual &&
    ++	GIT_TEST_DISALLOW_ABBREVIATED_OPTIONS=false \
    ++	test-tool early-scan-options --wanted-v x --wanted >actual &&
    ++	cat >expect <<-\EOF &&
    ++	found: wanted-value at 0 value: x
    ++	found: wanted at 2
    ++	stopped at: 3 of 3
    ++	EOF
     +	test_cmp expect actual
     +'
     +
    -+test_expect_success 'early_scan_options() takes values from struct option' '
    -+	test-tool early-scan-options --number --wanted >actual &&
    ++test_expect_success 'early_scan_options() skips the value of an abbreviation' '
    ++	GIT_TEST_DISALLOW_ABBREVIATED_OPTIONS=false \
    ++	test-tool early-scan-options --skipped --wanted >actual &&
     +	cat >expect <<-\EOF &&
     +	stopped at: 2 of 2
     +	EOF
    ++	test_cmp expect actual
    ++'
    ++
    ++test_expect_success 'early_scan_options() stops at an ambiguous abbreviation' '
    ++	GIT_TEST_DISALLOW_ABBREVIATED_OPTIONS=false \
    ++	test-tool early-scan-options --want --wanted >actual &&
    ++	cat >expect <<-\EOF &&
    ++	stopped at: 0 of 2
    ++	EOF
    ++	test_cmp expect actual
    ++'
    ++
    ++test_expect_success 'early_scan_options() stops at disallowed abbreviations' '
    ++	GIT_TEST_DISALLOW_ABBREVIATED_OPTIONS=true \
    ++	test-tool early-scan-options --wanted-v=x >actual &&
    ++	cat >expect <<-\EOF &&
    ++	stopped at: 0 of 1
    ++	EOF
     +	test_cmp expect actual &&
    -+	test-tool early-scan-options --number=5 --wanted >actual &&
    ++	GIT_TEST_DISALLOW_ABBREVIATED_OPTIONS=true \
    ++	test-tool early-scan-options --skipped --wanted >actual &&
    ++	cat >expect <<-\EOF &&
    ++	stopped at: 0 of 2
    ++	EOF
    ++	test_cmp expect actual
    ++'
    ++
    ++test_expect_success 'early_scan_options() stops at an unknown option' '
    ++	test-tool early-scan-options --bogus --wanted >actual &&
    ++	cat >expect <<-\EOF &&
    ++	stopped at: 0 of 2
    ++	EOF
    ++	test_cmp expect actual
    ++'
    ++
    ++test_expect_success 'early_scan_options() resolves aliases' '
    ++	test-tool early-scan-options --alias-of-skipped --wanted >actual &&
     +	cat >expect <<-\EOF &&
    -+	found: wanted at 1
     +	stopped at: 2 of 2
     +	EOF
    ++	test_cmp expect actual &&
    ++	test-tool early-scan-options --alias-of-skipped x --wanted >actual &&
    ++	cat >expect <<-\EOF &&
    ++	found: wanted at 2
    ++	stopped at: 3 of 3
    ++	EOF
     +	test_cmp expect actual
     +'
     +
    @@ t/t0040-parse-options.sh: test_expect_success 'u16 limits range' '
     +	stopped at: 2 of 2
     +	EOF
     +	test_cmp expect actual &&
    -+	test-tool early-scan-options --lastarg --wanted >actual &&
    ++	test-tool early-scan-options --optarg=5 --wanted >actual &&
     +	cat >expect <<-\EOF &&
     +	found: wanted at 1
     +	stopped at: 2 of 2
    @@ t/t0040-parse-options.sh: test_expect_success 'u16 limits range' '
     +	test_cmp expect actual
     +'
     +
    ++test_expect_success 'early_scan_options() skips a last argument default value' '
    ++	test-tool early-scan-options --lastarg --wanted >actual &&
    ++	cat >expect <<-\EOF &&
    ++	stopped at: 2 of 2
    ++	EOF
    ++	test_cmp expect actual &&
    ++	test-tool early-scan-options --wanted --lastarg >actual &&
    ++	cat >expect <<-\EOF &&
    ++	found: wanted at 0
    ++	stopped at: 2 of 2
    ++	EOF
    ++	test_cmp expect actual
    ++'
    ++
     +test_expect_success 'early_scan_options() matches a stuck optional value' '
     +	test-tool early-scan-options --early-optarg=one >actual &&
     +	cat >expect <<-\EOF &&
    @@ t/t0040-parse-options.sh: test_expect_success 'u16 limits range' '
     +	found: wanted at 1
     +	stopped at: 2 of 2
     +	EOF
    -+	test_cmp expect actual &&
    ++	test_cmp expect actual
    ++'
    ++
    ++test_expect_success 'early_scan_options() takes a last argument default value' '
     +	test-tool early-scan-options --early-lastarg --wanted >actual &&
     +	cat >expect <<-\EOF &&
    -+	found: early-lastarg at 0
    -+	found: wanted at 1
    ++	found: early-lastarg at 0 value: --wanted
     +	stopped at: 2 of 2
     +	EOF
    ++	test_cmp expect actual &&
    ++	test-tool early-scan-options --early-lastarg >actual &&
    ++	cat >expect <<-\EOF &&
    ++	found: early-lastarg at 0
    ++	stopped at: 1 of 1
    ++	EOF
     +	test_cmp expect actual
     +'
     +
    -+test_expect_success 'early_scan_options() ignores options without a long name' '
    ++test_expect_success 'early_scan_options() stops at short options' '
     +	test-tool early-scan-options -s --wanted >actual &&
     +	cat >expect <<-\EOF &&
    -+	found: wanted at 1
    -+	stopped at: 2 of 2
    ++	stopped at: 0 of 2
    ++	EOF
    ++	test_cmp expect actual
    ++'
    ++
    ++test_expect_success 'early_scan_options() stops at low-level callbacks' '
    ++	test-tool early-scan-options --low-level --wanted >actual &&
    ++	cat >expect <<-\EOF &&
    ++	stopped at: 0 of 2
    ++	EOF
    ++	test_cmp expect actual &&
    ++	test-tool early-scan-options --ll-value x --wanted >actual &&
    ++	cat >expect <<-\EOF &&
    ++	stopped at: 0 of 3
    ++	EOF
    ++	test_cmp expect actual
    ++'
    ++
    ++test_expect_success 'early_scan_options() stops after what the callback consumed' '
    ++	test-tool early-scan-options --stop-at-found --wanted --wanted >actual &&
    ++	cat >expect <<-\EOF &&
    ++	found: wanted at 0
    ++	stopped at: 1 of 2
    ++	EOF
    ++	test_cmp expect actual &&
    ++	test-tool early-scan-options --stop-at-found \
    ++		--wanted-value x --wanted >actual &&
    ++	cat >expect <<-\EOF &&
    ++	found: wanted-value at 0 value: x
    ++	stopped at: 2 of 3
     +	EOF
     +	test_cmp expect actual
     +'
     +
    -+test_expect_success 'early_scan_options() ignores negated options' '
    ++test_expect_success 'early_scan_options() reports negated options' '
     +	test-tool early-scan-options --no-wanted >actual &&
     +	cat >expect <<-\EOF &&
    ++	found: wanted at 0 unset
     +	stopped at: 1 of 1
     +	EOF
    ++	test_cmp expect actual &&
    ++	test-tool early-scan-options --early >actual &&
    ++	cat >expect <<-\EOF &&
    ++	found: no-early at 0 unset
    ++	stopped at: 1 of 1
    ++	EOF
    ++	test_cmp expect actual &&
    ++	test-tool early-scan-options --no-early >actual &&
    ++	cat >expect <<-\EOF &&
    ++	found: no-early at 0
    ++	stopped at: 1 of 1
    ++	EOF
    ++	test_cmp expect actual
    ++'
    ++
    ++test_expect_success 'early_scan_options() does not take a value for negated options' '
    ++	test-tool early-scan-options --no-wanted-value --wanted >actual &&
    ++	cat >expect <<-\EOF &&
    ++	found: wanted-value at 0 unset
    ++	found: wanted at 1
    ++	stopped at: 2 of 2
    ++	EOF
    ++	test_cmp expect actual &&
    ++	test-tool early-scan-options --no-wanted-value=x --wanted >actual &&
    ++	cat >expect <<-\EOF &&
    ++	stopped at: 0 of 2
    ++	EOF
     +	test_cmp expect actual
     +'
     +
3:  8e5092d421 ! 4:  515f13df83 fast-import: use early_scan_options() for --allow-unsafe-features
    @@ Commit message
         `--allow-unsafe-features` with PARSE_OPT_EARLY so that the scan
         reports it.
     
    -    Note that the scan still only matches the exact option spelling, while
    -    parse_options() also accepts unambiguous abbreviations, so the two still
    -    disagree for a command line like "--allow-unsafe". This errs on the safe
    -    side, and is now documented as a restriction.
    +    As early_scan_options() looks up long options the same way as
    +    parse_options(), the two also agree on abbreviations now. So
    +    `--allow-unsafe` allows unsafe "feature" commands, while
    +    "--quiet --export-pack --allow-unsafe-features" refuses them, as
    +    parse_options() takes `--allow-unsafe-features` there as the value of
    +    `--export-pack-edges`. (The old scan wrongly allowed them in this
    +    case, as all the arguments start with a dash.)
     
    +    The note about having to spell the option in full in the
    +    documentation is therefore removed, and so is the NEEDSWORK comment
    +    about it.
    +
    +    Helped-by: Kaartic Sivaraam <kaartic.sivaraam@gmail.com>
         Signed-off-by: Christian Couder <christian.couder@gmail.com>
     
      ## Documentation/git-fast-import.adoc ##
    -@@ Documentation/git-fast-import.adoc: fast-import stream! This option is enabled automatically for
    +@@ Documentation/git-fast-import.adoc: Only enable this option if you trust the program generating the
    + fast-import stream! This option is enabled automatically for
      remote-helpers that use the `import` capability, as they are
      already trusted to run their own code.
    - +
    +-+
     -Note that this option has to be spelled in full, and has to appear
     -before any option whose value is separated from it by a space, for
     -the unsafe `feature` commands in the stream to be allowed. So
     -`--allow-unsafe` or `--depth 5 --allow-unsafe-features` still refuse
     -them, while `--allow-unsafe-features --depth 5` and
     -`--depth=5 --allow-unsafe-features` allow them.
    -+Note that this option has to be spelled in full for the unsafe
    -+`feature` commands in the stream to be allowed. So `--allow-unsafe`
    -+is accepted as an unambiguous abbreviation of this option, but the
    -+unsafe `feature` commands are still refused.
      
      `--signed-tags=<mode>`::
      	Specify how to handle signed tags. Behaves in the same way as
    @@ builtin/fast-import.c: static int option_parse_quiet(const struct option *opt UN
      
     +static int option_parse_early_allow_unsafe(const struct option *option,
     +					   const char *value UNUSED,
    -+					   int pos UNUSED, void *data)
    ++					   bool unset, int pos UNUSED,
    ++					   void *data)
     +{
     +	struct fast_import_state *state = data;
     +
     +	if (!strcmp(option->long_name, "allow-unsafe-features"))
    -+		state->allow_unsafe_features = 1;
    ++		state->allow_unsafe_features = !unset;
     +	return 0;
     +}
     +
    @@ builtin/fast-import.c: int cmd_fast_import(int argc,
     -	 * this scan doesn't see it, so unsafe features from the stream are
     -	 * still refused. This errs on the safe side, but should be fixed by
     -	 * teaching this scan about the options that take a value.
    -+	 * NEEDSWORK: This scan only matches the exact
    -+	 * "--allow-unsafe-features" spelling, while parse_options() below
    -+	 * also accepts unambiguous abbreviations, so the two disagree for
    -+	 * a command line like "--allow-unsafe": parse_options() accepts
    -+	 * the option, but this scan doesn't see it, so unsafe features
    -+	 * from the stream are still refused. This errs on the safe side.
    ++	 * The scan uses the same options as parse_options() below, so the
    ++	 * two agree on which options take a value and on abbreviations. In
    ++	 * case of doubt, the scan stops early, so unsafe features are then
    ++	 * refused rather than wrongly allowed.
      	 */
     -	for (int i = 1; i < argc; i++) {
     -		const char *arg = argv[i];
    @@ t/t9300-fast-import.sh: test_expect_success 'R: export-marks options can be over
     +	git fast-import --date-format raw --allow-unsafe-features <input
     +'
     +
    -+test_expect_success 'R: --allow-unsafe-features has to be spelled in full' '
    ++test_expect_success 'R: --allow-unsafe-features can be abbreviated' '
    ++	echo "feature import-marks-if-exists=nonexistent.marks" >input &&
    ++	GIT_TEST_DISALLOW_ABBREVIATED_OPTIONS=false \
    ++	git fast-import --allow-unsafe <input
    ++'
    ++
    ++test_expect_success 'R: disallowed abbreviation of --allow-unsafe-features' '
    ++	echo "feature import-marks-if-exists=nonexistent.marks" >input &&
    ++	test_must_fail env GIT_TEST_DISALLOW_ABBREVIATED_OPTIONS=true \
    ++		git fast-import --allow-unsafe <input 2>err &&
    ++	test_grep "forbidden in input without --allow-unsafe-features" err
    ++'
    ++
    ++test_expect_success 'R: --allow-unsafe-features as the value of an abbreviation' '
     +	echo "feature import-marks-if-exists=nonexistent.marks" >input &&
    -+	test_must_fail git fast-import --allow-unsafe <input 2>err &&
    ++	test_must_fail env GIT_TEST_DISALLOW_ABBREVIATED_OPTIONS=false \
    ++		git fast-import --quiet --export-pack \
    ++		--allow-unsafe-features <input 2>err &&
     +	test_grep "forbidden in input without --allow-unsafe-features" err
     +'
     +

Christian Couder (4):
  parse-options: add parse_options_takes_argument()
  parse-options: extract lookup_long_opt() from parse_long_opt()
  parse-options: add early_scan_options()
  fast-import: use early_scan_options() for --allow-unsafe-features

 Documentation/git-fast-import.adoc            |   7 -
 .../technical/api-parse-options.adoc          |   5 +
 builtin/fast-import.c                         |  37 +-
 parse-options.c                               | 278 +++++++++++++--
 parse-options.h                               | 102 ++++++
 t/helper/test-parse-options.c                 |  96 ++++++
 t/helper/test-tool.c                          |   1 +
 t/helper/test-tool.h                          |   1 +
 t/t0040-parse-options.sh                      | 321 ++++++++++++++++++
 t/t9300-fast-import.sh                        |  29 ++
 10 files changed, 816 insertions(+), 61 deletions(-)


base-commit: a018953688f1b10bddf91bff8747068f5f4746a4
-- 
2.56.0.116.g6de20f6092


  parent reply	other threads:[~2026-10-12  7:44 UTC|newest]

Thread overview: 27+ messages / expand[flat|nested]  mbox.gz  Atom feed  top
2026-09-02 16:10 [PATCH 0/6] Standardize early option scanning to fix argument parsing bugs Christian Couder
2026-09-02 16:10 ` [PATCH 1/6] parse-options: add early_scan_options() Christian Couder
2026-09-02 22:11   ` Junio C Hamano
2026-09-23  8:10     ` Christian Couder
2026-09-23 17:25       ` Junio C Hamano
2026-09-02 16:10 ` [PATCH 2/6] bisect: fix "--" detection when a term name is "--" Christian Couder
2026-09-02 22:30   ` Junio C Hamano
2026-09-23  8:11     ` Christian Couder
2026-09-23 17:27       ` Junio C Hamano
2026-09-02 16:10 ` [PATCH 3/6] rev-parse: fix "--" detection when it is an option value Christian Couder
2026-09-02 16:10 ` [PATCH 4/6] parse-options: add parse_options_takes_argument() Christian Couder
2026-09-02 16:10 ` [PATCH 5/6] parse-options: build early scan options from a struct option array Christian Couder
2026-09-02 16:10 ` [PATCH 6/6] fast-import: use early_scan_options() for --allow-unsafe-features Christian Couder
2026-09-04  3:38   ` Junio C Hamano
2026-09-02 18:52 ` [PATCH 0/6] Standardize early option scanning to fix argument parsing bugs Junio C Hamano
2026-09-23  8:10   ` Christian Couder
2026-09-23  8:09 ` [PATCH v2 0/3] Standardize early option scanning Christian Couder
2026-09-23  8:09   ` [PATCH v2 1/3] parse-options: add parse_options_takes_argument() Christian Couder
2026-09-23  8:09   ` [PATCH v2 2/3] parse-options: add early_scan_options() Christian Couder
2026-09-30  7:13     ` Kaartic Sivaraam
2026-10-12  8:10       ` Christian Couder
2026-09-23  8:09   ` [PATCH v2 3/3] fast-import: use early_scan_options() for --allow-unsafe-features Christian Couder
2026-10-12  7:44   ` Christian Couder [this message]
2026-10-12  7:44     ` [PATCH v3 1/4] parse-options: add parse_options_takes_argument() Christian Couder
2026-10-12  7:44     ` [PATCH v3 2/4] parse-options: extract lookup_long_opt() from parse_long_opt() Christian Couder
2026-10-12  7:44     ` [PATCH v3 3/4] parse-options: add early_scan_options() Christian Couder
2026-10-12  7:44     ` [PATCH v3 4/4] fast-import: use early_scan_options() for --allow-unsafe-features Christian Couder

Reply instructions:

You may reply publicly to this message via plain-text email
using any one of the following methods:

* Save the following mbox file, import it into your mail client,
  and reply-to-all from there: mbox

  Avoid top-posting and favor interleaved quoting:
  https://en.wikipedia.org/wiki/Posting_style#Interleaved_style

* Reply using the --to, --cc, and --in-reply-to
  switches of git-send-email(1):

  git send-email \
    --in-reply-to=20261012074413.337183-1-christian.couder@gmail.com \
    --to=christian.couder@gmail.com \
    --cc=Johannes.Schindelin@gmx.de \
    --cc=git@vger.kernel.org \
    --cc=gitster@pobox.com \
    --cc=jltobler@gmail.com \
    --cc=kaartic.sivaraam@gmail.com \
    --cc=newren@gmail.com \
    --cc=peff@peff.net \
    --cc=ps@pks.im \
    --cc=sandals@crustytoothpaste.net \
    /path/to/YOUR_REPLY

  https://kernel.org/pub/software/scm/git/docs/git-send-email.html

* If your mail client supports setting the In-Reply-To header
  via mailto: links, try the mailto: link
Be sure your reply has a Subject: header at the top and a blank line before the message body.
This is a public inbox, see mirroring instructions
for how to clone and mirror all data and code used for this inbox