Repository: ipfs/kubo

Issue:
ipfs add --name does not exist
### Checklist

- [X] My issue is specific & actionable.
- [X] I am not suggesting a protocol enhancement.
- [X] I have searched on the [issue tracker](https://github.com/ipfs/kubo/issues?q=is%3Aissue) for my issue.

### Description

`ipfs pin --name` exists now. It would be nice to also have `ipfs add --name`.

Hints from issue comments:
@aschmahmann: What would this feature do, there are 2 places people put names:
- In the DAG (MFS, or with `ipfs add --wrap-with-directory`)
- In the local pin database

Is this a request for `ipfs add --pin=true --pin-name=<name>`?@gdkrmr: The proposal would be the equivalent to `ipfs pin --name`, i.e. add a name to the local database

> Is this a request for `ipfs add --pin=true --pin-name=<name>`?

That could be a possible implementation.@KapilSareen: Hi @aschmahmann, I would like to take this up!

Solution patch:
diff --git a/cmd/ipfs/kubo/add_migrations.go b/cmd/ipfs/kubo/add_migrations.go
index 557a8de84..d77d0afdf 100644
--- a/cmd/ipfs/kubo/add_migrations.go
+++ b/cmd/ipfs/kubo/add_migrations.go
@@ -86,7 +86,7 @@ func addMigrationFiles(ctx context.Context, node *core.IpfsNode, paths []string,
 			return err
 		}
 
-		ipfsPath, err := ufs.Add(ctx, files.NewReaderStatFile(f, fi), options.Unixfs.Pin(pin))
+		ipfsPath, err := ufs.Add(ctx, files.NewReaderStatFile(f, fi), options.Unixfs.Pin(pin, ""))
 		if err != nil {
 			return err
 		}
diff --git a/config/import.go b/config/import.go
index 21bf232c1..c51917286 100644
--- a/config/import.go
+++ b/config/import.go
@@ -21,7 +21,6 @@ const (
 	// write-batch. The total size of the batch is limited by
 	// BatchMaxnodes and BatchMaxSize.
 	DefaultBatchMaxSize = 100 << 20 // 20MiB
-
 )
 
 var (
diff --git a/core/commands/add.go b/core/commands/add.go
index f800e4f42..f9e374b80 100644
--- a/core/commands/add.go
+++ b/core/commands/add.go
@@ -37,6 +37,7 @@ type AddEvent struct {
 }
 
 const (
+	pinNameOptionName           = "pin-name"
 	quietOptionName             = "quiet"
 	quieterOptionName           = "quieter"
 	silentOptionName            = "silent"
@@ -184,6 +185,7 @@ See 'dag export' and 'dag import' for more information.
 		cmds.BoolOption(inlineOptionName, "Inline small blocks into CIDs. (experimental)"),
 		cmds.IntOption(inlineLimitOptionName, "Maximum block size to inline. (experimental)").WithDefault(32),
 		cmds.BoolOption(pinOptionName, "Pin locally to protect added files from garbage collection.").WithDefault(true),
+		cmds.StringOption(pinNameOptionName, "Name to use for the pin. Requires explicit value (e.g., --pin-name=myname)."),
 		cmds.StringOption(toFilesOptionName, "Add reference to Files API (MFS) at the provided path."),
 		cmds.BoolOption(preserveModeOptionName, "Apply existing POSIX permissions to created UnixFS entries. Disables raw-leaves. (experimental)"),
 		cmds.BoolOption(preserveMtimeOptionName, "Apply existing POSIX modification time to created UnixFS entries. Disables raw-leaves. (experimental)"),
@@ -230,6 +232,7 @@ See 'dag export' and 'dag import' for more information.
 		silent, _ := req.Options[silentOptionName].(bool)
 		chunker, _ := req.Options[chunkerOptionName].(string)
 		dopin, _ := req.Options[pinOptionName].(bool)
+		pinName, pinNameSet := req.Options[pinNameOptionName].(string)
 		rawblks, rbset := req.Options[rawLeavesOptionName].(bool)
 		maxFileLinks, maxFileLinksSet := req.Options[maxFileLinksOptionName].(int)
 		maxDirectoryLinks, maxDirectoryLinksSet := req.Options[maxDirectoryLinksOptionName].(int)
@@ -260,6 +263,8 @@ See 'dag export' and 'dag import' for more information.
 			cidVer = int(cfg.Import.CidVersion.WithDefault(config.DefaultCidVersion))
 		}
 
+		// Pin names are only used when explicitly provided via --pin-name=value
+
 		if !rbset && cfg.Import.UnixFSRawLeaves != config.Default {
 			rbset = true
 			rawblks = cfg.Import.UnixFSRawLeaves.WithDefault(config.DefaultUnixFSRawLeaves)
@@ -296,7 +301,9 @@ See 'dag export' and 'dag import' for more information.
 		if onlyHash && toFilesSet {
 			return fmt.Errorf("%s and %s options are not compatible", onlyHashOptionName, toFilesOptionName)
 		}
-
+		if !dopin && pinNameSet {
+			return fmt.Errorf("%s option requires %s to be set", pinNameOptionName, pinOptionName)
+		}
 		if wrap && toFilesSet {
 			return fmt.Errorf("%s and %s options are not compatible", wrapOptionName, toFilesOptionName)
 		}
@@ -326,7 +333,7 @@ See 'dag export' and 'dag import' for more information.
 
 			options.Unixfs.Chunker(chunker),
 
-			options.Unixfs.Pin(dopin),
+			options.Unixfs.Pin(dopin, pinName),
 			options.Unixfs.HashOnly(onlyHash),
 			options.Unixfs.FsCache(fscache),
 			options.Unixfs.Nocopy(nocopy),
diff --git a/core/coreapi/unixfs.go b/core/coreapi/unixfs.go
index eece797a5..6c78a869a 100644
--- a/core/coreapi/unixfs.go
+++ b/core/coreapi/unixfs.go
@@ -58,6 +58,7 @@ func (api *UnixfsAPI) Add(ctx context.Context, files files.Node, opts ...options
 		attribute.Bool("maxhamtfanoutset", settings.MaxHAMTFanoutSet),
 		attribute.Int("layout", int(settings.Layout)),
 		attribute.Bool("pin", settings.Pin),
+		attribute.String("pin-name", settings.PinName),
 		attribute.Bool("onlyhash", settings.OnlyHash),
 		attribute.Bool("fscache", settings.FsCache),
 		attribute.Bool("nocopy", settings.NoCopy),
@@ -136,6 +137,9 @@ func (api *UnixfsAPI) Add(ctx context.Context, files files.Node, opts ...options
 		fileAdder.Progress = settings.Progress
 	}
 	fileAdder.Pin = settings.Pin && !settings.OnlyHash
+	if settings.Pin {
+		fileAdder.PinName = settings.PinName
+	}
 	fileAdder.Silent = settings.Silent
 	fileAdder.RawLeaves = settings.RawLeaves
 	if settings.MaxFileLinksSet {
diff --git a/core/coreiface/options/unixfs.go b/core/coreiface/options/unixfs.go
index 20f18d1e0..45e880ed1 100644
--- a/core/coreiface/options/unixfs.go
+++ b/core/coreiface/options/unixfs.go
@@ -39,6 +39,7 @@ type UnixfsAddSettings struct {
 	Layout  Layout
 
 	Pin      bool
+	PinName  string
 	OnlyHash bool
 	FsCache  bool
 	NoCopy   bool
@@ -83,6 +84,7 @@ func UnixfsAddOptions(opts ...UnixfsAddOption) (*UnixfsAddSettings, cid.Prefix,
 		Layout:  BalancedLayout,
 
 		Pin:      false,
+		PinName:  "",
 		OnlyHash: false,
 		FsCache:  false,
 		NoCopy:   false,
@@ -280,9 +282,12 @@ func (unixfsOpts) Layout(layout Layout) UnixfsAddOption {
 }
 
 // Pin tells the adder to pin the file root recursively after adding
-func (unixfsOpts) Pin(pin bool) UnixfsAddOption {
+func (unixfsOpts) Pin(pin bool, pinName string) UnixfsAddOption {
 	return func(settings *UnixfsAddSettings) error {
 		settings.Pin = pin
+		if pin {
+			settings.PinName = pinName
+		}
 		return nil
 	}
 }
diff --git a/core/coreunix/add.go b/core/coreunix/add.go
index eb6f25e0f..c693773f9 100644
--- a/core/coreunix/add.go
+++ b/core/coreunix/add.go
@@ -76,6 +76,7 @@ type Adder struct {
 	Out               chan<- interface{}
 	Progress          bool
 	Pin               bool
+	PinName           string
 	Trickle           bool
 	RawLeaves         bool
 	MaxLinks          int
@@ -182,9 +183,10 @@ func (adder *Adder) curRootNode() (ipld.Node, error) {
 	return root, err
 }
 
-// Recursively pins the root node of Adder and
-// writes the pin state to the backing datastore.
-func (adder *Adder) PinRoot(ctx context.Context, root ipld.Node) error {
+// PinRoot recursively pins the root node of Adder with an optional name and
+// writes the pin state to the backing datastore. If name is empty, the pin
+// will be created without a name.
+func (adder *Adder) PinRoot(ctx context.Context, root ipld.Node, name string) error {
 	ctx, span := tracing.Span(ctx, "CoreUnix.Adder", "PinRoot")
 	defer span.End()
 
@@ -207,7 +209,7 @@ func (adder *Adder) PinRoot(ctx context.Context, root ipld.Node) error {
 		adder.tempRoot = rnk
 	}
 
-	err = adder.pinning.PinWithMode(ctx, rnk, pin.Recursive, "")
+	err = adder.pinning.PinWithMode(ctx, rnk, pin.Recursive, name)
 	if err != nil {
 		return err
 	}
@@ -369,7 +371,12 @@ func (adder *Adder) AddAllAndPin(ctx context.Context, file files.Node) (ipld.Nod
 	if !adder.Pin {
 		return nd, nil
 	}
-	return nd, adder.PinRoot(ctx, nd)
+
+	if err := adder.PinRoot(ctx, nd, adder.PinName); err != nil {
+		return nil, err
+	}
+
+	return nd, nil
 }
 
 func (adder *Adder) addFileNode(ctx context.Context, path string, file files.Node, toplevel bool) error {
@@ -530,7 +537,7 @@ func (adder *Adder) maybePauseForGC(ctx context.Context) error {
 			return err
 		}
 
-		err = adder.PinRoot(ctx, rn)
+		err = adder.PinRoot(ctx, rn, "")
 		if err != nil {
 			return err
 		}
diff --git a/docs/changelogs/v0.37.md b/docs/changelogs/v0.37.md
index 72e15d2da..8c264275b 100644
--- a/docs/changelogs/v0.37.md
+++ b/docs/changelogs/v0.37.md
@@ -11,6 +11,7 @@ This release  was brought to you by the [Interplanetary Shipyard](https://ipship
 - [Overview](#overview)
 - [🔦 Highlights](#-highlights)
   - [Clear provide queue when reprovide strategy changes](#clear-provide-queue-when-reprovide-strategy-changes)
+  - [Named pins in `ipfs add` command](#-named-pins-in-ipfs-add-command)
   - [Removed unnecessary dependencies](#removed-unnecessary-dependencies)
 - [📦️ Important dependency updates](#-important-dependency-updates)
 - [📝 Changelog](#-changelog)
@@ -31,6 +32,18 @@ A new `ipfs provide clear` command also allows manual queue clearing for debuggi
 > [!NOTE]
 > Upgrading to Kubo 0.37 will automatically clear any preexisting provide queue. The next time `Reprovider.Interval` hits, `Reprovider.Strategy` will be executed on a clean slate, ensuring consistent behavior with your current configuration.
 
+#### 🧷 Named pins in `ipfs add` command
+
+Added `--pin-name` flag to `ipfs add` for assigning names to pins.
+
+```console
+$ ipfs add --pin-name=testname cat.jpg
+added bafybeigdyrzt5sfp7udm7hu76uh7y26nf3efuylqabf3oclgtqy55fbzdi cat.jpg
+
+$ ipfs pin ls --names
+bafybeigdyrzt5sfp7udm7hu76uh7y26nf3efuylqabf3oclgtqy55fbzdi recursive testname
+```
+
 #### Removed unnecessary dependencies
 
 Kubo has been cleaned up by removing unnecessary dependencies and packages:



Test patch:
diff --git a/core/coreapi/test/path_test.go b/core/coreapi/test/path_test.go
index 692853a9a..f1337e809 100644
--- a/core/coreapi/test/path_test.go
+++ b/core/coreapi/test/path_test.go
@@ -39,7 +39,7 @@ func TestPathUnixFSHAMTPartial(t *testing.T) {
 		dir[strconv.Itoa(i)] = files.NewBytesFile([]byte(strconv.Itoa(i)))
 	}
 
-	r, err := a.Unixfs().Add(ctx, files.NewMapDirectory(dir), options.Unixfs.Pin(false))
+	r, err := a.Unixfs().Add(ctx, files.NewMapDirectory(dir), options.Unixfs.Pin(false, ""))
 	if err != nil {
 		t.Fatal(err)
 	}
diff --git a/core/coreiface/tests/unixfs.go b/core/coreiface/tests/unixfs.go
index 43447990e..c2717216c 100644
--- a/core/coreiface/tests/unixfs.go
+++ b/core/coreiface/tests/unixfs.go
@@ -539,7 +539,7 @@ func (tp *TestSuite) TestAddPinned(t *testing.T) {
 		t.Fatal(err)
 	}
 
-	_, err = api.Unixfs().Add(ctx, strFile(helloStr)(), options.Unixfs.Pin(true))
+	_, err = api.Unixfs().Add(ctx, strFile(helloStr)(), options.Unixfs.Pin(true, ""))
 	if err != nil {
 		t.Fatal(err)
 	}
diff --git a/test/cli/add_test.go b/test/cli/add_test.go
index 775a6063b..e4138b624 100644
--- a/test/cli/add_test.go
+++ b/test/cli/add_test.go
@@ -108,6 +108,44 @@ func TestAdd(t *testing.T) {
 		require.Equal(t, shortStringCidV1NoRawLeaves, cidStr)
 	})
 
+	t.Run("ipfs add --pin-name=foo", func(t *testing.T) {
+		t.Parallel()
+		node := harness.NewT(t).NewNode().Init().StartDaemon()
+		defer node.StopDaemon()
+
+		pinName := "test-pin-name"
+		cidStr := node.IPFSAddStr(shortString, "--pin-name", pinName)
+		require.Equal(t, shortStringCidV0, cidStr)
+
+		pinList := node.IPFS("pin", "ls", "--names").Stdout.Trimmed()
+		require.Contains(t, pinList, shortStringCidV0)
+		require.Contains(t, pinList, pinName)
+	})
+
+	t.Run("ipfs add --pin=false --pin-name=foo returns an error", func(t *testing.T) {
+		t.Parallel()
+
+		node := harness.NewT(t).NewNode().Init().StartDaemon()
+		defer node.StopDaemon()
+
+		// Use RunIPFS to allow for errors without assertion
+		result := node.RunIPFS("add", "--pin=false", "--pin-name=foo")
+		require.Error(t, result.Err, "Expected an error due to incompatible --pin and --pin-name")
+		require.Contains(t, result.Stderr.String(), "pin-name option requires pin to be set")
+	})
+
+	t.Run("ipfs add --pin-name without value should fail", func(t *testing.T) {
+		t.Parallel()
+
+		node := harness.NewT(t).NewNode().Init().StartDaemon()
+		defer node.StopDaemon()
+
+		// When --pin-name is passed without any value, it should fail
+		result := node.RunIPFS("add", "--pin-name")
+		require.Error(t, result.Err, "Expected an error when --pin-name has no value")
+		require.Contains(t, result.Stderr.String(), "missing argument for option \"pin-name\"")
+	})
+
 	t.Run("produced unixfs max file links: command flag --max-file-links overrides configuration in Import.UnixFSFileMaxLinks", func(t *testing.T) {
 		t.Parallel()