Skip to content

Local: true breaks multi-value flags (slice/map) — PreParse resets on every Set call #2281

@idelchi

Description

@idelchi

My urfave/cli version is

v3.7.0 (also reproduced on latest main at commit a107ee4)

Checklist

  • Are you running the latest v3 release? The list of releases is here.
  • Did you check the manual for your release? The v3 manual is here
  • Did you perform a search about this problem? Here's the GitHub guide about searching.

Dependency Management

  • My project is using go modules.
  • My project is using vendoring.

Describe the bug

StringSliceFlag (and StringMapFlag) with Local: true only retains the last value when multiple values are passed via repeated flags. For example, -p /a -p /b -p /c results in [/c] instead of [/a /b /c].

The cause is FlagBase.Set() in flag_impl.go:188. For Local flags, Set() re-runs PreParse() on every invocation:

if !f.applied || f.Local {
    if err := f.PreParse(); err != nil {
        return err
    }
    f.applied = true
}

PreParse() calls f.creator.Create(newVal, f.Destination, f.Config), which for SliceBase unconditionally resets the destination to empty (flag_slice_base.go:20):

func (i SliceBase[T, C, VC]) Create(val []T, p *[]T, c C) Value {
    *p = []T{}           // resets destination
    *p = append(*p, val...)  // copies in default (empty)
    ...
}

So each -p call: resets dest = [] via PreParse, then appends the single new value. Only the last survives.

For non-Local flags this doesn't happen because f.applied is true after the first Set(), short-circuiting the condition. The f.Local disjunction defeats that guard.

This affects all multi-value flag types (StringSliceFlag, IntSliceFlag, StringMapFlag, etc.) when combined with Local: true. Single-value flags are unaffected because overwriting is idempotent for them.

To reproduce

package main

import (
    "context"
    "fmt"
    "os"

    cli "github.com/urfave/cli/v3"
)

func main() {
    var paths []string

    app := &cli.Command{
        Name: "app",
        Commands: []*cli.Command{
            {
                Name: "sub",
                Flags: []cli.Flag{
                    &cli.StringSliceFlag{
                        Name:        "paths",
                        Aliases:     []string{"p"},
                        Local:       true,
                        Destination: &paths,
                    },
                },
                Action: func(_ context.Context, cmd *cli.Command) error {
                    fmt.Printf("Destination: %v (len=%d)\n", paths, len(paths))
                    fmt.Printf("cmd.StringSlice: %v\n", cmd.StringSlice("paths"))
                    return nil
                },
            },
        },
    }

    app.Run(context.Background(), os.Args)
}
go run . sub -p /a -p /b -p /c

Observed behavior

Destination: [/c] (len=1)
cmd.StringSlice: [/c]

Only the last value is retained. Both Destination and cmd.StringSlice() return the same broken result — the bug is in parsing, not in reading.

DisableSliceFlagSeparator: true does not help.

Expected behavior

Destination: [/a /b /c] (len=3)
cmd.StringSlice: [/a /b /c]

All three values should accumulate. Removing Local: true from the flag definition produces the correct result.

Additional context

Test against latest main (a107ee4):

func TestLocalSliceFlagAccumulation(t *testing.T) {
    var got []string

    app := &cli.Command{
        Name: "app",
        Commands: []*cli.Command{
            {
                Name: "sub",
                Flags: []cli.Flag{
                    &cli.StringSliceFlag{
                        Name:        "paths",
                        Aliases:     []string{"p"},
                        Local:       true,
                        Destination: &got,
                    },
                },
                Action: func(_ context.Context, cmd *cli.Command) error {
                    return nil
                },
            },
        },
    }

    err := app.Run(context.Background(), []string{"app", "sub", "-p", "/a", "-p", "/b", "-p", "/c"})
    if err != nil {
        t.Fatal(err)
    }

    if len(got) != 3 {
        t.Errorf("expected 3 values, got %d: %v", len(got), got)
    }
}
--- FAIL: TestLocalSliceFlagAccumulation (0.00s)
    local_slice_test.go:39: expected 3 values, got 1: [/c]

The fix is straightforward — Set() should not re-run PreParse() for an already-initialized multi-value flag. Something like:

if !f.applied || (f.Local && !f.IsMultiValueFlag()) {

Or alternatively, guard inside PreParse / SliceBase.Create to not reset when the value is already initialized. Happy to open a PR.

Want to fix this yourself?

Yes. The fix is a one-line change in flag_impl.go:188. I have a failing test and a working patch ready.

Run go version and paste its output here

go version go1.26.1 linux/amd64

Run go env and paste its output here

n/a — bug is in urfave/cli internals, not environment-specific

Metadata

Metadata

Assignees

No one assigned

    Labels

    area/v3relates to / is being considered for v3kind/bugdescribes or fixes a bugstatus/triagemaintainers still need to look into this

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions