Skip to content

Commit bf5578b

Browse files
committed
feat(add): add --dereference-symlinks, --empty-dirs, --hidden CLI flags
add CLI flags for controlling file collection behavior during ipfs add: - `--dereference-symlinks`: recursively resolve symlinks to their target content (replaces deprecated --dereference-args which only worked on CLI arguments). wired through go-ipfs-cmds to boxo's SerialFileOptions. - `--empty-dirs` / `-E`: include empty directories (default: true) - `--hidden` / `-H`: include hidden files (default: false) these flags are CLI-only and not wired to Import.* config options because go-ipfs-cmds library handles input file filtering before the directory tree is passed to kubo. removed unused Import.UnixFSSymlinkMode config option that was defined but never actually read by the CLI. also: - wire --trickle to Import.UnixFSDAGLayout config default - update go-ipfs-cmds to v0.15.1-0.20260117043932-17687e216294 - add SYMLINK HANDLING section to ipfs add help text - add CLI tests for all three flags ref: ipfs/specs#499
1 parent f5427b5 commit bf5578b

File tree

16 files changed

+332
-116
lines changed

16 files changed

+332
-116
lines changed

config/import.go

Lines changed: 1 addition & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -35,17 +35,13 @@ const (
3535
HAMTSizeEstimationBlock = "block" // full serialized dag-pb block size
3636
HAMTSizeEstimationDisabled = "disabled" // disable HAMT sharding entirely
3737

38-
// SymlinkMode values for Import.UnixFSSymlinkMode
39-
SymlinkModePreserve = "preserve" // preserve symlinks as UnixFS symlink nodes
40-
SymlinkModeDereference = "dereference" // dereference symlinks, import target content
41-
4238
// DAGLayout values for Import.UnixFSDAGLayout
4339
DAGLayoutBalanced = "balanced" // balanced DAG layout (default)
4440
DAGLayoutTrickle = "trickle" // trickle DAG layout
4541

4642
DefaultUnixFSHAMTDirectorySizeEstimation = HAMTSizeEstimationLinks // legacy behavior
47-
DefaultUnixFSSymlinkMode = SymlinkModePreserve // preserve symlinks as UnixFS symlink nodes
4843
DefaultUnixFSDAGLayout = DAGLayoutBalanced // balanced DAG layout
44+
DefaultUnixFSIncludeEmptyDirs = true // include empty directories
4945
)
5046

5147
var (
@@ -66,7 +62,6 @@ type Import struct {
6662
UnixFSHAMTDirectoryMaxFanout OptionalInteger
6763
UnixFSHAMTDirectorySizeThreshold OptionalBytes
6864
UnixFSHAMTDirectorySizeEstimation OptionalString // "links", "block", or "disabled"
69-
UnixFSSymlinkMode OptionalString // "preserve" or "dereference"
7065
UnixFSDAGLayout OptionalString // "balanced" or "trickle"
7166
BatchMaxNodes OptionalInteger
7267
BatchMaxSize OptionalInteger
@@ -161,18 +156,6 @@ func ValidateImportConfig(cfg *Import) error {
161156
}
162157
}
163158

164-
// Validate UnixFSSymlinkMode
165-
if !cfg.UnixFSSymlinkMode.IsDefault() {
166-
mode := cfg.UnixFSSymlinkMode.WithDefault(DefaultUnixFSSymlinkMode)
167-
switch mode {
168-
case SymlinkModePreserve, SymlinkModeDereference:
169-
// valid
170-
default:
171-
return fmt.Errorf("Import.UnixFSSymlinkMode must be %q or %q, got %q",
172-
SymlinkModePreserve, SymlinkModeDereference, mode)
173-
}
174-
}
175-
176159
// Validate UnixFSDAGLayout
177160
if !cfg.UnixFSDAGLayout.IsDefault() {
178161
layout := cfg.UnixFSDAGLayout.WithDefault(DefaultUnixFSDAGLayout)

config/import_test.go

Lines changed: 0 additions & 37 deletions
Original file line numberDiff line numberDiff line change
@@ -446,43 +446,6 @@ func TestValidateImportConfig_HAMTSizeEstimation(t *testing.T) {
446446
}
447447
}
448448

449-
func TestValidateImportConfig_SymlinkMode(t *testing.T) {
450-
tests := []struct {
451-
name string
452-
value string
453-
wantErr bool
454-
errMsg string
455-
}{
456-
{name: "valid preserve", value: SymlinkModePreserve, wantErr: false},
457-
{name: "valid dereference", value: SymlinkModeDereference, wantErr: false},
458-
{name: "invalid unknown", value: "unknown", wantErr: true, errMsg: "must be"},
459-
{name: "invalid empty", value: "", wantErr: true, errMsg: "must be"},
460-
{name: "invalid follow", value: "follow", wantErr: true, errMsg: "must be"},
461-
}
462-
463-
for _, tt := range tests {
464-
t.Run(tt.name, func(t *testing.T) {
465-
cfg := &Import{
466-
UnixFSSymlinkMode: *NewOptionalString(tt.value),
467-
}
468-
469-
err := ValidateImportConfig(cfg)
470-
471-
if tt.wantErr {
472-
if err == nil {
473-
t.Errorf("expected error for value=%q, got nil", tt.value)
474-
} else if tt.errMsg != "" && !strings.Contains(err.Error(), tt.errMsg) {
475-
t.Errorf("error = %v, want error containing %q", err, tt.errMsg)
476-
}
477-
} else {
478-
if err != nil {
479-
t.Errorf("unexpected error for value=%q: %v", tt.value, err)
480-
}
481-
}
482-
})
483-
}
484-
}
485-
486449
func TestValidateImportConfig_DAGLayout(t *testing.T) {
487450
tests := []struct {
488451
name string

config/profile.go

Lines changed: 0 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -338,7 +338,6 @@ See https://github.com/ipfs/specs/pull/499`,
338338
c.Import.UnixFSHAMTDirectoryMaxFanout = *NewOptionalInteger(256)
339339
c.Import.UnixFSHAMTDirectorySizeThreshold = *NewOptionalBytes("256KiB")
340340
c.Import.UnixFSHAMTDirectorySizeEstimation = *NewOptionalString(HAMTSizeEstimationBlock)
341-
c.Import.UnixFSSymlinkMode = *NewOptionalString(SymlinkModePreserve)
342341
c.Import.UnixFSDAGLayout = *NewOptionalString(DAGLayoutBalanced)
343342
return nil
344343
},
@@ -436,7 +435,6 @@ func applyUnixFSv02015(c *Config) error {
436435
c.Import.UnixFSHAMTDirectoryMaxFanout = *NewOptionalInteger(256)
437436
c.Import.UnixFSHAMTDirectorySizeThreshold = *NewOptionalBytes("256KiB")
438437
c.Import.UnixFSHAMTDirectorySizeEstimation = *NewOptionalString(HAMTSizeEstimationLinks)
439-
c.Import.UnixFSSymlinkMode = *NewOptionalString(SymlinkModePreserve)
440438
c.Import.UnixFSDAGLayout = *NewOptionalString(DAGLayoutBalanced)
441439
return nil
442440
}

core/commands/add.go

Lines changed: 32 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -68,6 +68,7 @@ const (
6868
mtimeNsecsOptionName = "mtime-nsecs"
6969
fastProvideRootOptionName = "fast-provide-root"
7070
fastProvideWaitOptionName = "fast-provide-wait"
71+
emptyDirsOptionName = "empty-dirs"
7172
)
7273

7374
const (
@@ -147,6 +148,18 @@ to find it in the future:
147148
See 'ipfs files --help' to learn more about using MFS
148149
for keeping track of added files and directories.
149150
151+
SYMLINK HANDLING:
152+
153+
By default, symbolic links are preserved as UnixFS symlink nodes that store
154+
the target path. Use --dereference-symlinks to resolve symlinks to their
155+
target content instead:
156+
157+
> ipfs add -r --dereference-symlinks ./mydir
158+
159+
This recursively resolves all symlinks encountered during directory traversal.
160+
Symlinks to files become regular file content, symlinks to directories are
161+
traversed and their contents are added.
162+
150163
CHUNKING EXAMPLES:
151164
152165
The chunker option, '-s', specifies the chunking strategy that dictates
@@ -200,11 +213,13 @@ https://github.com/ipfs/kubo/blob/master/docs/config.md#import
200213
Options: []cmds.Option{
201214
// Input Processing
202215
cmds.OptionRecursivePath, // a builtin option that allows recursive paths (-r, --recursive)
203-
cmds.OptionDerefArgs, // a builtin option that resolves passed in filesystem links (--dereference-args)
216+
cmds.OptionDerefArgs, // DEPRECATED: use --dereference-symlinks instead
204217
cmds.OptionStdinName, // a builtin option that optionally allows wrapping stdin into a named file
205218
cmds.OptionHidden,
206219
cmds.OptionIgnore,
207220
cmds.OptionIgnoreRules,
221+
cmds.BoolOption(emptyDirsOptionName, "E", "Include empty directories in the import.").WithDefault(config.DefaultUnixFSIncludeEmptyDirs),
222+
cmds.OptionDerefSymlinks, // resolve symlinks to their target content
208223
// Output Control
209224
cmds.BoolOption(quietOptionName, "q", "Write minimal output."),
210225
cmds.BoolOption(quieterOptionName, "Q", "Write only final hash."),
@@ -274,7 +289,7 @@ https://github.com/ipfs/kubo/blob/master/docs/config.md#import
274289
}
275290

276291
progress, _ := req.Options[progressOptionName].(bool)
277-
trickle, _ := req.Options[trickleOptionName].(bool)
292+
trickle, trickleSet := req.Options[trickleOptionName].(bool)
278293
wrap, _ := req.Options[wrapOptionName].(bool)
279294
onlyHash, _ := req.Options[onlyHashOptionName].(bool)
280295
silent, _ := req.Options[silentOptionName].(bool)
@@ -312,6 +327,19 @@ https://github.com/ipfs/kubo/blob/master/docs/config.md#import
312327
mtimeNsecs, _ := req.Options[mtimeNsecsOptionName].(uint)
313328
fastProvideRoot, fastProvideRootSet := req.Options[fastProvideRootOptionName].(bool)
314329
fastProvideWait, fastProvideWaitSet := req.Options[fastProvideWaitOptionName].(bool)
330+
emptyDirs, _ := req.Options[emptyDirsOptionName].(bool)
331+
332+
// Handle --dereference-args deprecation
333+
derefArgs, derefArgsSet := req.Options[cmds.DerefLong].(bool)
334+
if derefArgsSet && derefArgs {
335+
return fmt.Errorf("--dereference-args is deprecated: use --dereference-symlinks instead")
336+
}
337+
338+
// Wire --trickle from config
339+
if !trickleSet && !cfg.Import.UnixFSDAGLayout.IsDefault() {
340+
layout := cfg.Import.UnixFSDAGLayout.WithDefault(config.DefaultUnixFSDAGLayout)
341+
trickle = layout == config.DAGLayoutTrickle
342+
}
315343

316344
if chunker == "" {
317345
chunker = cfg.Import.UnixFSChunker.WithDefault(config.DefaultUnixFSChunker)
@@ -409,6 +437,8 @@ https://github.com/ipfs/kubo/blob/master/docs/config.md#import
409437

410438
options.Unixfs.PreserveMode(preserveMode),
411439
options.Unixfs.PreserveMtime(preserveMtime),
440+
441+
options.Unixfs.IncludeEmptyDirs(emptyDirs),
412442
}
413443

414444
if mode != 0 {

core/coreapi/unixfs.go

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -183,6 +183,9 @@ func (api *UnixfsAPI) Add(ctx context.Context, files files.Node, opts ...options
183183
fileAdder.PreserveMtime = settings.PreserveMtime
184184
fileAdder.FileMode = settings.Mode
185185
fileAdder.FileMtime = settings.Mtime
186+
if settings.IncludeEmptyDirsSet {
187+
fileAdder.IncludeEmptyDirs = settings.IncludeEmptyDirs
188+
}
186189

187190
switch settings.Layout {
188191
case options.BalancedLayout:

core/coreiface/options/unixfs.go

Lines changed: 21 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -48,10 +48,12 @@ type UnixfsAddSettings struct {
4848
Silent bool
4949
Progress bool
5050

51-
PreserveMode bool
52-
PreserveMtime bool
53-
Mode os.FileMode
54-
Mtime time.Time
51+
PreserveMode bool
52+
PreserveMtime bool
53+
Mode os.FileMode
54+
Mtime time.Time
55+
IncludeEmptyDirs bool
56+
IncludeEmptyDirsSet bool
5557
}
5658

5759
type UnixfsLsSettings struct {
@@ -93,10 +95,12 @@ func UnixfsAddOptions(opts ...UnixfsAddOption) (*UnixfsAddSettings, cid.Prefix,
9395
Silent: false,
9496
Progress: false,
9597

96-
PreserveMode: false,
97-
PreserveMtime: false,
98-
Mode: 0,
99-
Mtime: time.Time{},
98+
PreserveMode: false,
99+
PreserveMtime: false,
100+
Mode: 0,
101+
Mtime: time.Time{},
102+
IncludeEmptyDirs: true, // default: include empty directories
103+
IncludeEmptyDirsSet: false,
100104
}
101105

102106
for _, opt := range opts {
@@ -396,3 +400,12 @@ func (unixfsOpts) Mtime(seconds int64, nsecs uint32) UnixfsAddOption {
396400
return nil
397401
}
398402
}
403+
404+
// IncludeEmptyDirs tells the adder to include empty directories in the DAG
405+
func (unixfsOpts) IncludeEmptyDirs(include bool) UnixfsAddOption {
406+
return func(settings *UnixfsAddSettings) error {
407+
settings.IncludeEmptyDirs = include
408+
settings.IncludeEmptyDirsSet = true
409+
return nil
410+
}
411+
}

core/coreunix/add.go

Lines changed: 41 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,7 @@ import (
2626
"github.com/ipfs/go-cid"
2727
ipld "github.com/ipfs/go-ipld-format"
2828
logging "github.com/ipfs/go-log/v2"
29+
"github.com/ipfs/kubo/config"
2930
coreiface "github.com/ipfs/kubo/core/coreiface"
3031

3132
"github.com/ipfs/kubo/tracing"
@@ -52,17 +53,18 @@ func NewAdder(ctx context.Context, p pin.Pinner, bs bstore.GCLocker, ds ipld.DAG
5253
bufferedDS := ipld.NewBufferedDAG(ctx, ds)
5354

5455
return &Adder{
55-
ctx: ctx,
56-
pinning: p,
57-
gcLocker: bs,
58-
dagService: ds,
59-
bufferedDS: bufferedDS,
60-
Progress: false,
61-
Pin: true,
62-
Trickle: false,
63-
MaxLinks: ihelper.DefaultLinksPerBlock,
64-
MaxHAMTFanout: uio.DefaultShardWidth,
65-
Chunker: "",
56+
ctx: ctx,
57+
pinning: p,
58+
gcLocker: bs,
59+
dagService: ds,
60+
bufferedDS: bufferedDS,
61+
Progress: false,
62+
Pin: true,
63+
Trickle: false,
64+
MaxLinks: ihelper.DefaultLinksPerBlock,
65+
MaxHAMTFanout: uio.DefaultShardWidth,
66+
Chunker: "",
67+
IncludeEmptyDirs: config.DefaultUnixFSIncludeEmptyDirs,
6668
}, nil
6769
}
6870

@@ -91,10 +93,11 @@ type Adder struct {
9193
CidBuilder cid.Builder
9294
liveNodes uint64
9395

94-
PreserveMode bool
95-
PreserveMtime bool
96-
FileMode os.FileMode
97-
FileMtime time.Time
96+
PreserveMode bool
97+
PreserveMtime bool
98+
FileMode os.FileMode
99+
FileMtime time.Time
100+
IncludeEmptyDirs bool
98101
}
99102

100103
func (adder *Adder) mfsRoot() (*mfs.Root, error) {
@@ -480,6 +483,24 @@ func (adder *Adder) addFile(path string, file files.File) error {
480483
func (adder *Adder) addDir(ctx context.Context, path string, dir files.Directory, toplevel bool) error {
481484
log.Infof("adding directory: %s", path)
482485

486+
// Peek at first entry to check if directory is empty.
487+
// We advance the iterator once here and continue from this position
488+
// in the processing loop below. This avoids allocating a slice to
489+
// collect all entries just to check for emptiness.
490+
it := dir.Entries()
491+
hasEntry := it.Next()
492+
if !hasEntry {
493+
if err := it.Err(); err != nil {
494+
return err
495+
}
496+
// Directory is empty. Skip it unless IncludeEmptyDirs is set or
497+
// this is the toplevel directory (we always include the root).
498+
if !adder.IncludeEmptyDirs && !toplevel {
499+
log.Debugf("skipping empty directory: %s", path)
500+
return nil
501+
}
502+
}
503+
483504
// if we need to store mode or modification time then create a new root which includes that data
484505
if toplevel && (adder.FileMode != 0 || !adder.FileMtime.IsZero()) {
485506
mr, err := mfs.NewEmptyRoot(ctx, adder.dagService, nil, nil,
@@ -515,13 +536,14 @@ func (adder *Adder) addDir(ctx context.Context, path string, dir files.Directory
515536
}
516537
}
517538

518-
it := dir.Entries()
519-
for it.Next() {
539+
// Process directory entries. The iterator was already advanced once above
540+
// to peek for emptiness, so we start from that position.
541+
for hasEntry {
520542
fpath := gopath.Join(path, it.Name())
521-
err := adder.addFileNode(ctx, fpath, it.Node(), false)
522-
if err != nil {
543+
if err := adder.addFileNode(ctx, fpath, it.Node(), false); err != nil {
523544
return err
524545
}
546+
hasEntry = it.Next()
525547
}
526548

527549
return it.Err()

docs/changelogs/v0.40.md

Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ This release was brought to you by the [Shipyard](https://ipshipyard.com/) team.
1010

1111
- [Overview](#overview)
1212
- [🔦 Highlights](#-highlights)
13+
- [🔢 UnixFS CID Profiles (IPIP-499)](#-unixfs-cid-profiles-ipip-499)
1314
- [🧹 Automatic cleanup of interrupted imports](#-automatic-cleanup-of-interrupted-imports)
1415
- [Routing V1 HTTP API now exposed by default](#routing-v1-http-api-now-exposed-by-default)
1516
- [Track total size when adding pins](#track-total-size-when-adding-pins)
@@ -30,6 +31,43 @@ This release was brought to you by the [Shipyard](https://ipshipyard.com/) team.
3031

3132
### 🔦 Highlights
3233

34+
#### 🔢 UnixFS CID Profiles (IPIP-499)
35+
36+
This release introduces [IPIP-499](https://github.com/ipfs/specs/pull/499) UnixFS CID Profiles for reproducible CID generation across IPFS implementations.
37+
38+
**New Profiles**
39+
40+
- `unixfs-v1-2025`: the recommended profile for CIDv1 imports with deterministic HAMT directory sharding based on block size estimation
41+
- `unixfs-v0-2015` (alias `legacy-cid-v0`): preserves legacy CIDv0 behavior for backward compatibility
42+
43+
Apply a profile with: `ipfs config profile apply unixfs-v1-2025`
44+
45+
**New `Import.*` Configuration Options**
46+
47+
New [`Import.*`](https://github.com/ipfs/kubo/blob/master/docs/config.md#import) options allow fine-grained control over import parameters:
48+
49+
- `Import.CidVersion`: CID version (0 or 1)
50+
- `Import.HashFunction`: hash algorithm
51+
- `Import.UnixFSChunker`: chunking strategy
52+
- `Import.UnixFSRawLeaves`: raw leaf blocks
53+
- `Import.UnixFSFileMaxLinks`: max children per file node
54+
- `Import.UnixFSDirectoryMaxLinks`: max children per basic directory
55+
- `Import.UnixFSHAMTDirectoryMaxFanout`: HAMT shard width
56+
- `Import.UnixFSHAMTDirectorySizeThreshold`: threshold for HAMT sharding
57+
- `Import.UnixFSHAMTDirectorySizeEstimation`: estimation mode (`links`, `block`, or `disabled`)
58+
- `Import.UnixFSDAGLayout`: DAG layout (`balanced` or `trickle`)
59+
60+
**Deprecated Profiles**
61+
62+
The `test-cid-v1` and `test-cid-v1-wide` profiles have been removed. Use `unixfs-v1-2025` for CIDv1 imports.
63+
64+
**CLI Changes**
65+
66+
- New `--dereference-symlinks` flag for `ipfs add` recursively resolves symlinks to their target content (replaces deprecated `--dereference-args` which only worked on CLI arguments)
67+
- New `--empty-dirs` / `-E` flag for `ipfs add` controls inclusion of empty directories (default: true)
68+
- New `--hidden` / `-H` flag for `ipfs add` includes hidden files (default: false)
69+
- The `--trickle` flag in `ipfs add` now respects `Import.UnixFSDAGLayout` config default
70+
3371
#### 🧹 Automatic cleanup of interrupted imports
3472

3573
If you cancel `ipfs add` or `ipfs dag import` mid-operation, Kubo now automatically cleans up incomplete data on the next daemon start. Previously, interrupted imports would leave orphan blocks in your repository that were difficult to identify and remove without pins and running explicit garbage collection.

0 commit comments

Comments
 (0)