Skip to content

Commit 01b1ce0

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 01b1ce0

File tree

16 files changed

+331
-115
lines changed

16 files changed

+331
-115
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: 1 addition & 38 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
@@ -528,7 +491,7 @@ func TestImport_HAMTSizeEstimationMode(t *testing.T) {
528491
{HAMTSizeEstimationLinks, io.SizeEstimationLinks},
529492
{HAMTSizeEstimationBlock, io.SizeEstimationBlock},
530493
{HAMTSizeEstimationDisabled, io.SizeEstimationDisabled},
531-
{"", io.SizeEstimationLinks}, // default (unset returns default)
494+
{"", io.SizeEstimationLinks}, // default (unset returns default)
532495
{"unknown", io.SizeEstimationLinks}, // fallback to default
533496
}
534497

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()

0 commit comments

Comments
 (0)