Repository: dundee/gdu (go)

Issue:
Get top large files
I have a scenario where one of my Kubernetes nodes is experiencing disk pressure. Due to my use of the `Bottlerocket` OS, it's somewhat challenging to run utilities that are not containerized.

I tried to use the `gdu` docker image and get the files that cause the disk pressure, unfortunately without any success.

It would be amazing if I could run a k8s job like this and get the top X large files
```
apiVersion: batch/v1
kind: Job
metadata:
  name: disk-space-checker
  labels:
    app: disk-space-checker
    release: disk-space-checker
spec:
  backoffLimit: 0
  parallelism: 1
  ttlSecondsAfterFinished: 3600 # keep job for 1 hour 
  activeDeadlineSeconds: 900 # timeout after 15 min   
  template:
    metadata:
      labels:
        app: disk-space-checker
    spec:
      restartPolicy: Never
      containers:
      - name: disk-space-checker
        image: ghcr.io/dundee/gdu:v5.28.0
        imagePullPolicy: IfNotPresent
        securityContext:
          runAsUser: 0
        resources:
          requests:
            cpu: 250m
        args: ["--non-interactive","--show-apparent-size","--show-item-count","--max-cores","4","--no-delete","--ignore-dirs","/host/proc,/host/dev,/host/sys,/host/run,/host/local,/host/mnt,/host/etc,/host/boot","--no-cross","/host"]
        volumeMounts:
        - name: host
          mountPath: "/host"
          readOnly: true
      volumes:
        - name: host
          hostPath:
            path: "/"
```
With this k8s job I gets only root directories without getting sub directories and files.

Any chance to have additional parameter that can sort top X files by size?




Pull request description:
feat: show top largest files
closes #348

Patch:
diff --git a/README.md b/README.md
index 36f847c..09aec9d 100644
--- a/README.md
+++ b/README.md
@@ -71,6 +71,7 @@ Flags:
       --si                            Show sizes with decimal SI prefixes (kB, MB, GB) instead of binary prefixes (KiB, MiB, GiB)
       --storage-path string           Path to persistent key-value storage directory (default "/tmp/badger")
   -s, --summarize                     Show only a total in non-interactive mode
+  -t, --top int                       Show only top X largest files in non-interactive mode
       --use-storage                   Use persistent key-value storage for analysis data (experimental)
   -v, --version                       Print version
       --write-config                  Write current configuration to file (default is $HOME/.gdu.yaml)
@@ -104,6 +105,7 @@ Basic list of actions in interactive mode (show help modal for more):
     gdu -n /                              # only print stats, do not start interactive mode
     gdu -np /                             # do not show progress, useful when using its output in a script
     gdu -nps /some/dir                    # show only total usage for given dir
+    gdu -nt 10 /                          # show top 10 largest files
     gdu / > file                          # write stats to file, do not start interactive mode
 
     gdu -o- / | gzip -c >report.json.gz   # write all info to JSON file for later analysis
diff --git a/cmd/gdu/app/app.go b/cmd/gdu/app/app.go
index 5860453..1a27d30 100644
--- a/cmd/gdu/app/app.go
+++ b/cmd/gdu/app/app.go
@@ -72,6 +72,7 @@ type Flags struct {
 	StoragePath        string   `yaml:"storage-path"`
 	ReadFromStorage    bool     `yaml:"read-from-storage"`
 	Summarize          bool     `yaml:"summarize"`
+	Top                int      `yaml:"top"`
 	UseSIPrefix        bool     `yaml:"use-si-prefix"`
 	NoPrefix           bool     `yaml:"no-prefix"`
 	WriteConfig        bool     `yaml:"-"`
@@ -242,6 +243,7 @@ func (a *App) createUI() (UI, error) {
 			a.Flags.ConstGC,
 			a.Flags.UseSIPrefix,
 			a.Flags.NoPrefix,
+			a.Flags.Top,
 		)
 		if a.Flags.NoUnicode {
 			stdoutUI.UseOldProgressRunes()
diff --git a/cmd/gdu/main.go b/cmd/gdu/main.go
index b7ae04d..b256f7b 100644
--- a/cmd/gdu/main.go
+++ b/cmd/gdu/main.go
@@ -74,6 +74,7 @@ func init() {
 	flags.BoolVarP(&af.NoProgress, "no-progress", "p", false, "Do not show progress in non-interactive mode")
 	flags.BoolVarP(&af.NoUnicode, "no-unicode", "u", false, "Do not use Unicode symbols (for size bar)")
 	flags.BoolVarP(&af.Summarize, "summarize", "s", false, "Show only a total in non-interactive mode")
+	flags.IntVarP(&af.Top, "top", "t", 0, "Show only top X largest files in non-interactive mode")
 	flags.BoolVar(&af.UseSIPrefix, "si", false, "Show sizes with decimal SI prefixes (kB, MB, GB) instead of binary prefixes (KiB, MiB, GiB)")
 	flags.BoolVar(&af.NoPrefix, "no-prefix", false, "Show sizes as raw numbers without any prefixes (SI or binary) in non-interactive mode")
 	flags.BoolVar(&af.NoMouse, "no-mouse", false, "Do not use mouse")
diff --git a/gdu.1.md b/gdu.1.md
index 7a87670..cf9ecce 100644
--- a/gdu.1.md
+++ b/gdu.1.md
@@ -54,6 +54,8 @@ non-interactive mode
 
 **-s**, **\--summarize**\[=false\] Show only a total in non-interactive mode
 
+**-t**, **\--top**\[=0\] Show only top X largest files in non-interactive mode
+
 **-d**, **\--show-disks**\[=false\] Show all mounted disks
 
 **-a**, **\--show-apparent-size**\[=false\] Show apparent size
diff --git a/pkg/analyze/top.go b/pkg/analyze/top.go
new file mode 100644
index 0000000..b7e569c
--- /dev/null
+++ b/pkg/analyze/top.go
@@ -0,0 +1,53 @@
+package analyze
+
+import (
+	"sort"
+
+	"github.com/dundee/gdu/v5/pkg/fs"
+)
+
+// TopList is a list of top largest files
+type TopList struct {
+	Count   int
+	Items   fs.Files
+	MinSize int64
+}
+
+// NewTopList creates new TopList
+func NewTopList(count int) *TopList {
+	return &TopList{Count: count}
+}
+
+// Add adds file to the list
+func (tl *TopList) Add(file fs.Item) {
+	if len(tl.Items) < tl.Count {
+		tl.Items = append(tl.Items, file)
+		if file.GetSize() > tl.MinSize {
+			tl.MinSize = file.GetSize()
+		}
+	} else if file.GetSize() > tl.MinSize {
+		tl.Items = append(tl.Items, file)
+		tl.MinSize = file.GetSize()
+		if len(tl.Items) > tl.Count {
+			sort.Sort(fs.ByApparentSize(tl.Items))
+			tl.Items = tl.Items[1:]
+		}
+	}
+}
+
+func CollectTopFiles(dir fs.Item, count int) fs.Files {
+	topList := NewTopList(count)
+	walkDir(dir, topList)
+	sort.Sort(sort.Reverse(fs.ByApparentSize(topList.Items)))
+	return topList.Items
+}
+
+func walkDir(dir fs.Item, topList *TopList) {
+	for _, item := range dir.GetFiles() {
+		if item.IsDir() {
+			walkDir(item, topList)
+		} else {
+			topList.Add(item)
+		}
+	}
+}
diff --git a/stdout/stdout.go b/stdout/stdout.go
index ced65fd..84dfcac 100644
--- a/stdout/stdout.go
+++ b/stdout/stdout.go
@@ -26,6 +26,7 @@ type UI struct {
 	blue      *color.Color
 	summarize bool
 	noPrefix  bool
+	top       int
 }
 
 var (
@@ -45,6 +46,7 @@ func CreateStdoutUI(
 	constGC bool,
 	useSIPrefix bool,
 	noPrefix bool,
+	top int,
 ) *UI {
 	ui := &UI{
 		UI: &common.UI{
@@ -59,6 +61,7 @@ func CreateStdoutUI(
 		output:    output,
 		summarize: summarize,
 		noPrefix:  noPrefix,
+		top:       top,
 	}
 
 	ui.red = color.New(color.FgRed).Add(color.Bold)
@@ -167,9 +170,12 @@ func (ui *UI) AnalyzePath(path string, _ fs.Item) error {
 
 	wait.Wait()
 
-	if ui.summarize {
+	switch {
+	case ui.top > 0:
+		ui.printTopFiles(dir)
+	case ui.summarize:
 		ui.printTotalItem(dir)
-	} else {
+	default:
 		ui.showDir(dir)
 	}
 
@@ -187,9 +193,12 @@ func (ui *UI) ReadFromStorage(storagePath, path string) error {
 		return err
 	}
 
-	if ui.summarize {
+	switch {
+	case ui.top > 0:
+		ui.printTopFiles(dir)
+	case ui.summarize:
 		ui.printTotalItem(dir)
-	} else {
+	default:
 		ui.showDir(dir)
 	}
 	return nil
@@ -203,6 +212,13 @@ func (ui *UI) showDir(dir fs.Item) {
 	}
 }
 
+func (ui *UI) printTopFiles(file fs.Item) {
+	collected := analyze.CollectTopFiles(file, ui.top)
+	for _, file := range collected {
+		ui.printItemPath(file)
+	}
+}
+
 func (ui *UI) printTotalItem(file fs.Item) {
 	var lineFormat string
 	if ui.UseColors {
@@ -256,6 +272,27 @@ func (ui *UI) printItem(file fs.Item) {
 	}
 }
 
+func (ui *UI) printItemPath(file fs.Item) {
+	var lineFormat string
+	if ui.UseColors {
+		lineFormat = "%20s %s\n"
+	} else {
+		lineFormat = "%9s %s\n"
+	}
+
+	var size int64
+	if ui.ShowApparentSize {
+		size = file.GetSize()
+	} else {
+		size = file.GetUsage()
+	}
+
+	fmt.Fprintf(ui.output,
+		lineFormat,
+		ui.formatSize(size),
+		file.GetPath())
+}
+
 // ReadAnalysis reads analysis report from JSON file
 func (ui *UI) ReadAnalysis(input io.Reader) error {
 	var (


Test patch:
diff --git a/pkg/analyze/top_test.go b/pkg/analyze/top_test.go
new file mode 100644
index 0000000..7d89cc6
--- /dev/null
+++ b/pkg/analyze/top_test.go
@@ -0,0 +1,38 @@
+package analyze
+
+import (
+	"testing"
+
+	"github.com/dundee/gdu/v5/internal/testdir"
+	"github.com/stretchr/testify/assert"
+)
+
+func TestCollectTopFiles2(t *testing.T) {
+	fin := testdir.CreateTestDir()
+	defer fin()
+
+	dir := CreateAnalyzer().AnalyzeDir(
+		"test_dir", func(_, _ string) bool { return false }, false,
+	)
+
+	topFiles := CollectTopFiles(dir, 2)
+	assert.Equal(t, 2, len(topFiles))
+	assert.Equal(t, "file", topFiles[0].GetName())
+	assert.Equal(t, int64(5), topFiles[0].GetSize())
+	assert.Equal(t, "file2", topFiles[1].GetName())
+	assert.Equal(t, int64(2), topFiles[1].GetSize())
+}
+
+func TestCollectTopFiles1(t *testing.T) {
+	fin := testdir.CreateTestDir()
+	defer fin()
+
+	dir := CreateAnalyzer().AnalyzeDir(
+		"test_dir", func(_, _ string) bool { return false }, false,
+	)
+
+	topFiles := CollectTopFiles(dir, 1)
+	assert.Equal(t, 1, len(topFiles))
+	assert.Equal(t, "file", topFiles[0].GetName())
+	assert.Equal(t, int64(5), topFiles[0].GetSize())
+}
diff --git a/stdout/stdout_linux_test.go b/stdout/stdout_linux_test.go
index 4a47dae..781d11b 100644
--- a/stdout/stdout_linux_test.go
+++ b/stdout/stdout_linux_test.go
@@ -21,7 +21,7 @@ func TestShowDevicesWithErr(t *testing.T) {
 	output := bytes.NewBuffer(make([]byte, 10))
 
 	getter := device.LinuxDevicesInfoGetter{MountsPath: "/xyzxyz"}
-	ui := CreateStdoutUI(output, false, true, false, false, false, false, false, false)
+	ui := CreateStdoutUI(output, false, true, false, false, false, false, false, false, 0)
 	err := ui.ListDevices(getter)
 
 	assert.Contains(t, err.Error(), "no such file")
diff --git a/stdout/stdout_test.go b/stdout/stdout_test.go
index cd76bd9..95dad49 100644
--- a/stdout/stdout_test.go
+++ b/stdout/stdout_test.go
@@ -25,7 +25,7 @@ func TestAnalyzePath(t *testing.T) {
 	buff := make([]byte, 10)
 	output := bytes.NewBuffer(buff)
 
-	ui := CreateStdoutUI(output, false, false, false, false, false, true, false, false)
+	ui := CreateStdoutUI(output, false, false, false, false, false, true, false, false, 0)
 	ui.SetIgnoreDirPaths([]string{"/xxx"})
 	err := ui.AnalyzePath("test_dir", nil)
 	assert.Nil(t, err)
@@ -42,7 +42,7 @@ func TestShowSummary(t *testing.T) {
 	buff := make([]byte, 10)
 	output := bytes.NewBuffer(buff)
 
-	ui := CreateStdoutUI(output, true, false, true, false, true, false, false, false)
+	ui := CreateStdoutUI(output, true, false, true, false, true, false, false, false, 0)
 	ui.SetIgnoreDirPaths([]string{"/xxx"})
 	err := ui.AnalyzePath("test_dir", nil)
 	assert.Nil(t, err)
@@ -59,7 +59,7 @@ func TestShowSummaryBw(t *testing.T) {
 	buff := make([]byte, 10)
 	output := bytes.NewBuffer(buff)
 
-	ui := CreateStdoutUI(output, false, false, false, false, true, false, false, false)
+	ui := CreateStdoutUI(output, false, false, false, false, true, false, false, false, 0)
 	ui.SetIgnoreDirPaths([]string{"/xxx"})
 	err := ui.AnalyzePath("test_dir", nil)
 	assert.Nil(t, err)
@@ -69,6 +69,42 @@ func TestShowSummaryBw(t *testing.T) {
 	assert.Contains(t, output.String(), "test_dir")
 }
 
+func TestShowTop(t *testing.T) {
+	fin := testdir.CreateTestDir()
+	defer fin()
+
+	buff := make([]byte, 10)
+	output := bytes.NewBuffer(buff)
+
+	ui := CreateStdoutUI(output, true, false, true, false, true, false, false, false, 2)
+	ui.SetIgnoreDirPaths([]string{"/xxx"})
+	err := ui.AnalyzePath("test_dir", nil)
+	assert.Nil(t, err)
+	err = ui.StartUILoop()
+
+	assert.Nil(t, err)
+	assert.Contains(t, output.String(), "test_dir/nested/subnested/file")
+	assert.Contains(t, output.String(), "test_dir/nested/file2")
+}
+
+func TestShowTopBw(t *testing.T) {
+	fin := testdir.CreateTestDir()
+	defer fin()
+
+	buff := make([]byte, 10)
+	output := bytes.NewBuffer(buff)
+
+	ui := CreateStdoutUI(output, false, false, false, false, true, false, false, false, 2)
+	ui.SetIgnoreDirPaths([]string{"/xxx"})
+	err := ui.AnalyzePath("test_dir", nil)
+	assert.Nil(t, err)
+	err = ui.StartUILoop()
+
+	assert.Nil(t, err)
+	assert.Contains(t, output.String(), "test_dir/nested/subnested/file")
+	assert.Contains(t, output.String(), "test_dir/nested/file2")
+}
+
 func TestAnalyzeSubdir(t *testing.T) {
 	fin := testdir.CreateTestDir()
 	defer fin()
@@ -76,7 +112,7 @@ func TestAnalyzeSubdir(t *testing.T) {
 	buff := make([]byte, 10)
 	output := bytes.NewBuffer(buff)
 
-	ui := CreateStdoutUI(output, false, false, false, false, false, false, false, false)
+	ui := CreateStdoutUI(output, false, false, false, false, false, false, false, false, 0)
 	ui.SetIgnoreDirPaths([]string{"/xxx"})
 	err := ui.AnalyzePath("test_dir/nested", nil)
 	assert.Nil(t, err)
@@ -93,7 +129,7 @@ func TestAnalyzePathWithColors(t *testing.T) {
 	buff := make([]byte, 10)
 	output := bytes.NewBuffer(buff)
 
-	ui := CreateStdoutUI(output, true, false, true, false, false, false, false, false)
+	ui := CreateStdoutUI(output, true, false, true, false, false, false, false, false, 0)
 	ui.SetIgnoreDirPaths([]string{"/xxx"})
 	err := ui.AnalyzePath("test_dir/nested", nil)
 
@@ -108,7 +144,7 @@ func TestAnalyzePathWoUnicode(t *testing.T) {
 	buff := make([]byte, 10)
 	output := bytes.NewBuffer(buff)
 
-	ui := CreateStdoutUI(output, false, true, true, false, false, false, false, false)
+	ui := CreateStdoutUI(output, false, true, true, false, false, false, false, false, 0)
 	ui.UseOldProgressRunes()
 	err := ui.AnalyzePath("test_dir/nested", nil)
 
@@ -119,7 +155,7 @@ func TestAnalyzePathWoUnicode(t *testing.T) {
 func TestItemRows(t *testing.T) {
 	output := bytes.NewBuffer(make([]byte, 10))
 
-	ui := CreateStdoutUI(output, false, true, false, false, false, false, false, false)
+	ui := CreateStdoutUI(output, false, true, false, false, false, false, false, false, 0)
 	ui.Analyzer = &testanalyze.MockedAnalyzer{}
 	err := ui.AnalyzePath("test_dir", nil)
 
@@ -133,7 +169,7 @@ func TestAnalyzePathWithProgress(t *testing.T) {
 
 	output := bytes.NewBuffer(make([]byte, 10))
 
-	ui := CreateStdoutUI(output, false, true, true, false, false, false, false, false)
+	ui := CreateStdoutUI(output, false, true, true, false, false, false, false, false, 0)
 	ui.SetIgnoreDirPaths([]string{"/xxx"})
 	err := ui.AnalyzePath("test_dir", nil)
 
@@ -144,7 +180,7 @@ func TestAnalyzePathWithProgress(t *testing.T) {
 func TestShowDevices(t *testing.T) {
 	output := bytes.NewBuffer(make([]byte, 10))
 
-	ui := CreateStdoutUI(output, false, true, false, false, false, false, false, false)
+	ui := CreateStdoutUI(output, false, true, false, false, false, false, false, false, 0)
 	err := ui.ListDevices(getDevicesInfoMock())
 
 	assert.Nil(t, err)
@@ -155,7 +191,7 @@ func TestShowDevices(t *testing.T) {
 func TestShowDevicesWithColor(t *testing.T) {
 	output := bytes.NewBuffer(make([]byte, 10))
 
-	ui := CreateStdoutUI(output, true, true, true, false, false, false, false, false)
+	ui := CreateStdoutUI(output, true, true, true, false, false, false, false, false, 0)
 	err := ui.ListDevices(getDevicesInfoMock())
 
 	assert.Nil(t, err)
@@ -169,7 +205,7 @@ func TestReadAnalysisWithColor(t *testing.T) {
 
 	output := bytes.NewBuffer(make([]byte, 10))
 
-	ui := CreateStdoutUI(output, true, true, true, false, false, false, false, false)
+	ui := CreateStdoutUI(output, true, true, true, false, false, false, false, false, 0)
 	err = ui.ReadAnalysis(input)
 
 	assert.Nil(t, err)
@@ -182,7 +218,7 @@ func TestReadAnalysisBw(t *testing.T) {
 
 	output := bytes.NewBuffer(make([]byte, 10))
 
-	ui := CreateStdoutUI(output, false, false, false, false, false, false, false, false)
+	ui := CreateStdoutUI(output, false, false, false, false, false, false, false, false, 0)
 	err = ui.ReadAnalysis(input)
 
 	assert.Nil(t, err)
@@ -195,7 +231,7 @@ func TestReadAnalysisWithWrongFile(t *testing.T) {
 
 	output := bytes.NewBuffer(make([]byte, 10))
 
-	ui := CreateStdoutUI(output, true, true, true, false, false, false, false, false)
+	ui := CreateStdoutUI(output, true, true, true, false, false, false, false, false, 0)
 	err = ui.ReadAnalysis(input)
 
 	assert.NotNil(t, err)
@@ -207,7 +243,7 @@ func TestReadAnalysisWithSummarize(t *testing.T) {
 
 	output := bytes.NewBuffer(make([]byte, 10))
 
-	ui := CreateStdoutUI(output, false, false, false, false, true, false, false, false)
+	ui := CreateStdoutUI(output, false, false, false, false, true, false, false, false, 0)
 	err = ui.ReadAnalysis(input)
 
 	assert.Nil(t, err)
@@ -222,7 +258,7 @@ func TestMaxInt(t *testing.T) {
 func TestFormatSize(t *testing.T) {
 	output := bytes.NewBuffer(make([]byte, 10))
 
-	ui := CreateStdoutUI(output, true, true, true, false, false, false, false, false)
+	ui := CreateStdoutUI(output, true, true, true, false, false, false, false, false, 0)
 
 	assert.Contains(t, ui.formatSize(1), "B")
 	assert.Contains(t, ui.formatSize(1<<10+1), "KiB")
@@ -236,7 +272,7 @@ func TestFormatSize(t *testing.T) {
 func TestFormatSizeDec(t *testing.T) {
 	output := bytes.NewBuffer(make([]byte, 10))
 
-	ui := CreateStdoutUI(output, true, true, true, false, false, false, true, false)
+	ui := CreateStdoutUI(output, true, true, true, false, false, false, true, false, 0)
 
 	assert.Contains(t, ui.formatSize(1), "B")
 	assert.Contains(t, ui.formatSize(1<<10+1), "kB")
@@ -250,7 +286,7 @@ func TestFormatSizeDec(t *testing.T) {
 func TestFormatSizeRaw(t *testing.T) {
 	output := bytes.NewBuffer(make([]byte, 10))
 
-	ui := CreateStdoutUI(output, true, true, true, false, false, false, true, true)
+	ui := CreateStdoutUI(output, true, true, true, false, false, false, true, true, 0)
 
 	assert.Equal(t, ui.formatSize(1), "1")
 	assert.Equal(t, ui.formatSize(1<<10+1), "1025")