Repository: dundee/gdu
Pull request #404 (2025-02-22 15:32:14)

feat: Add support showing size of absent git-annex'ed files
git-annex encodes the apparent size of files their symlinks.

gdu now is capable of extracting those sizes from the broken symlinks to calculate the total size of git-annex repositories.

Reference: https://git-annex.branchable.com/internals/key_format/

**Note:** real usage remains zero. gdu needs to be invoked via:

```shell
gdu --follow-symlinks --show-apparent-size --show-annexed-size
```

stv0g: I fully understand, if this PR is a bit out-of-scope for gdu. Maybe other git-annex users might find it useful :)
codecov[bot]: ## [Codecov](https://app.codecov.io/gh/dundee/gdu/pull/404?dropdown=coverage&src=pr&el=h1&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=Daniel+Milde) Report
Attention: Patch coverage is `15.71429%` with `59 lines` in your changes missing coverage. Please review.
> Project coverage is 86.54%. Comparing base [(`a0fd828`)](https://app.codecov.io/gh/dundee/gdu/commit/a0fd828dc686d82dfd41b9039f65d0b211e11f86?dropdown=coverage&el=desc&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=Daniel+Milde) to head [(`90399bf`)](https://app.codecov.io/gh/dundee/gdu/commit/90399bff1be355958e371ebdfd122be6e03390c9?dropdown=coverage&el=desc&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=Daniel+Milde).

| [Files with missing lines](https://app.codecov.io/gh/dundee/gdu/pull/404?dropdown=coverage&src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=Daniel+Milde) | Patch % | Lines |
|---|---|---|
| [pkg/annex/annex.go](https://app.codecov.io/gh/dundee/gdu/pull/404?src=pr&el=tree&filepath=pkg%2Fannex%2Fannex.go&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=Daniel+Milde#diff-cGtnL2FubmV4L2FubmV4Lmdv) | 0.00% | [31 Missing :warning: ](https://app.codecov.io/gh/dundee/gdu/pull/404?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=Daniel+Milde) |
| [pkg/analyze/symlink.go](https://app.codecov.io/gh/dundee/gdu/pull/404?src=pr&el=tree&filepath=pkg%2Fanalyze%2Fsymlink.go&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=Daniel+Milde#diff-cGtnL2FuYWx5emUvc3ltbGluay5nbw==) | 33.33% | [12 Missing and 2 partials :warning: ](https://app.codecov.io/gh/dundee/gdu/pull/404?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=Daniel+Milde) |
| [cmd/gdu/main.go](https://app.codecov.io/gh/dundee/gdu/pull/404?src=pr&el=tree&filepath=cmd%2Fgdu%2Fmain.go&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=Daniel+Milde#diff-Y21kL2dkdS9tYWluLmdv) | 0.00% | [4 Missing :warning: ](https://app.codecov.io/gh/dundee/gdu/pull/404?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=Daniel+Milde) |
| [cmd/gdu/app/app.go](https://app.codecov.io/gh/dundee/gdu/pull/404?src=pr&el=tree&filepath=cmd%2Fgdu%2Fapp%2Fapp.go&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=Daniel+Milde#diff-Y21kL2dkdS9hcHAvYXBwLmdv) | 0.00% | [2 Missing and 1 partial :warning: ](https://app.codecov.io/gh/dundee/gdu/pull/404?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=Daniel+Milde) |
| [pkg/analyze/parallel.go](https://app.codecov.io/gh/dundee/gdu/pull/404?src=pr&el=tree&filepath=pkg%2Fanalyze%2Fparallel.go&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=Daniel+Milde#diff-cGtnL2FuYWx5emUvcGFyYWxsZWwuZ28=) | 33.33% | [2 Missing :warning: ](https://app.codecov.io/gh/dundee/gdu/pull/404?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=Daniel+Milde) |
| [pkg/analyze/sequential.go](https://app.codecov.io/gh/dundee/gdu/pull/404?src=pr&el=tree&filepath=pkg%2Fanalyze%2Fsequential.go&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=Daniel+Milde#diff-cGtnL2FuYWx5emUvc2VxdWVudGlhbC5nbw==) | 33.33% | [2 Missing :warning: ](https://app.codecov.io/gh/dundee/gdu/pull/404?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=Daniel+Milde) |
| [pkg/analyze/stored.go](https://app.codecov.io/gh/dundee/gdu/pull/404?src=pr&el=tree&filepath=pkg%2Fanalyze%2Fstored.go&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=Daniel+Milde#diff-cGtnL2FuYWx5emUvc3RvcmVkLmdv) | 0.00% | [2 Missing :warning: ](https://app.codecov.io/gh/dundee/gdu/pull/404?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=Daniel+Milde) |
| [internal/testanalyze/analyze.go](https://app.codecov.io/gh/dundee/gdu/pull/404?src=pr&el=tree&filepath=internal%2Ftestanalyze%2Fanalyze.go&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=Daniel+Milde#diff-aW50ZXJuYWwvdGVzdGFuYWx5emUvYW5hbHl6ZS5nbw==) | 0.00% | [1 Missing :warning: ](https://app.codecov.io/gh/dundee/gdu/pull/404?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=Daniel+Milde) |

<details><summary>Additional details and impacted files</summary>


```diff
@@            Coverage Diff             @@
##           master     #404      +/-   ##
==========================================
- Coverage   87.64%   86.54%   -1.10%     
==========================================
  Files          44       46       +2     
  Lines        4329     4384      +55     
==========================================
  Hits         3794     3794              
- Misses        460      513      +53     
- Partials       75       77       +2     
```

</details>

[:umbrella: View full report in Codecov by Sentry](https://app.codecov.io/gh/dundee/gdu/pull/404?dropdown=coverage&src=pr&el=continue&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=Daniel+Milde).   
:loudspeaker: Have feedback on the report? [Share it here](https://about.codecov.io/codecov-pr-comment-feedback/?utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=Daniel+Milde).

dundee: I have fixed few small issues. Could you cover your logic with tests?
stv0g: Hi Daniel,

Yes, I can implement some test cases. The use of `EvalSymlinks` unfortunately, does not work for me, as it iteratively calls `os.Readlink` which break if any component of the link does not exist.

git-annex stores files in this hierarchy:

```                                                                                                                                                                                                                                                                                                                       
      ├─► .git                                                                                                                                                                                                                                                                                                                   
      │    └─► annex                                                                                                                                                                                                                                                                                                          
      │         └─► objects                                                                                                                                                                                                                                                                                              
      │              └─► 7d                                                                                                                                                                                                                                                                                        
      │                   └─► 86                                                                                                                                                                                                                                                                      
      │                        └─►SHA256E-s43632--7d865e959b2466918c9863afca942d0fb89d7c9ac0c99bafc3749504ded97730.jpg                                                                                                                                                                              
      │                             └─► SHA256E-s43632--7d865e959b2466918c9863afca942d0fb89d7c9ac0c99bafc3749504ded97730.jpg                                                                                                                                                                                        
      ├──► existing_file.jpg -> .git/annex/objects/7d/86/SHA256E-s43632--7d865e959b2466918c9863afca942d0fb89d7c9ac0c99bafc3749504ded97730.jpg                                                                                                                                                                          
      └──► missing_file.zip -> .git/annex/objects/08/d2/SHA256E-s744506832--08d2daced60b5eb6509044d5eefca82e7a6899350f49adc0083014229739515e.zip    (broken symlink)                                                                                                                                                                       
```

Due two-levels of hash directories, and a dedicated directory per link, causes `EvalSymlinks` to fail.

For the purpose of this PR, I am only interested in the `-s<SIZE>--` fragment of the symlink.
dundee: I have updated the code to contain both `EvalSymlinks` (which is needed for proper handling of symlinks in subdirectories) and `Readlink` fot git-annex. Wdyt?

It seems to work for me fine.

```shell
$ go run github.com/dundee/gdu/v5/cmd/gdu -LAan ~/annex
  1.1   MiB /.git
@ 923.0 MiB file.mp4

 $ go run github.com/dundee/gdu/v5/cmd/gdu -an ~/annex
   1.1 MiB /.git
@  202 B file.mp4
``` 

Sorry for taking so long, I wanted to get hands on Git annex.
stv0g: Hey @dundee,

oh thats great :) Yes, I think this is the best solution.

Thanks for looking into it :) I did not expect, that you will look into git-annex :)
stv0g: I've also pushed a fix to make golangci-lint happy.

Patch:
diff --git a/cmd/gdu/app/app.go b/cmd/gdu/app/app.go
index 135a4a7..7797b95 100644
--- a/cmd/gdu/app/app.go
+++ b/cmd/gdu/app/app.go
@@ -36,6 +36,7 @@ type UI interface {
 	SetIgnoreFromFile(ignoreFile string) error
 	SetIgnoreHidden(value bool)
 	SetFollowSymlinks(value bool)
+	SetShowAnnexedSize(value bool)
 	SetAnalyzer(analyzer common.Analyzer)
 	StartUILoop() error
 }
@@ -54,6 +55,7 @@ type Flags struct {
 	ShowDisks          bool     `yaml:"-"`
 	ShowApparentSize   bool     `yaml:"show-apparent-size"`
 	ShowRelativeSize   bool     `yaml:"show-relative-size"`
+	ShowAnnexedSize    bool     `yaml:"show-annexed-size"`
 	ShowVersion        bool     `yaml:"-"`
 	ShowItemCount      bool     `yaml:"show-item-count"`
 	ShowMTime          bool     `yaml:"show-mtime"`
@@ -183,6 +185,9 @@ func (a *App) Run() error {
 	if a.Flags.FollowSymlinks {
 		ui.SetFollowSymlinks(true)
 	}
+	if a.Flags.ShowAnnexedSize {
+		ui.SetShowAnnexedSize(true)
+	}
 	if err := a.setNoCross(path); err != nil {
 		return err
 	}
diff --git a/cmd/gdu/main.go b/cmd/gdu/main.go
index b165f92..6190a62 100644
--- a/cmd/gdu/main.go
+++ b/cmd/gdu/main.go
@@ -59,6 +59,10 @@ func init() {
 		&af.FollowSymlinks, "follow-symlinks", "L", false,
 		"Follow symlinks for files, i.e. show the size of the file to which symlink points to (symlinks to directories are not followed)",
 	)
+	flags.BoolVarP(
+		&af.ShowAnnexedSize, "show-annexed-size", "A", false,
+		"Use apparent size of git-annex'ed files in case files are not present locally (real usage is zero)",
+	)
 	flags.BoolVarP(&af.NoCross, "no-cross", "x", false, "Do not cross filesystem boundaries")
 	flags.BoolVarP(&af.ConstGC, "const-gc", "g", false, "Enable memory garbage collection during analysis with constant level set by GOGC")
 	flags.BoolVar(&af.Profiling, "enable-profiling", false, "Enable collection of profiling data and provide it on http://localhost:6060/debug/pprof/")
diff --git a/internal/common/analyze.go b/internal/common/analyze.go
index bb08b3c..bef4a4f 100644
--- a/internal/common/analyze.go
+++ b/internal/common/analyze.go
@@ -16,6 +16,7 @@ type ShouldDirBeIgnored func(name, path string) bool
 type Analyzer interface {
 	AnalyzeDir(path string, ignore ShouldDirBeIgnored, constGC bool) fs.Item
 	SetFollowSymlinks(bool)
+	SetShowAnnexedSize(bool)
 	GetProgressChan() chan CurrentProgress
 	GetDone() SignalGroup
 	ResetProgress()
diff --git a/internal/common/ui.go b/internal/common/ui.go
index 2caefd3..8f93039 100644
--- a/internal/common/ui.go
+++ b/internal/common/ui.go
@@ -29,6 +29,11 @@ func (ui *UI) SetFollowSymlinks(v bool) {
 	ui.Analyzer.SetFollowSymlinks(v)
 }
 
+// SetShowAnnexedSize sets whether to use annexed size of git-annex files
+func (ui *UI) SetShowAnnexedSize(v bool) {
+	ui.Analyzer.SetShowAnnexedSize(v)
+}
+
 // binary multiplies prefixes (IEC)
 const (
 	_ float64 = 1 << (10 * iota)
diff --git a/pkg/analyze/parallel.go b/pkg/analyze/parallel.go
index 7fa517a..62dd9f6 100644
--- a/pkg/analyze/parallel.go
+++ b/pkg/analyze/parallel.go
@@ -23,6 +23,7 @@ type ParallelAnalyzer struct {
 	wait             *WaitGroup
 	ignoreDir        common.ShouldDirBeIgnored
 	followSymlinks   bool
+	gitAnnexedSize   bool
 }
 
 // CreateAnalyzer returns Analyzer
@@ -45,6 +46,11 @@ func (a *ParallelAnalyzer) SetFollowSymlinks(v bool) {
 	a.followSymlinks = v
 }
 
+// SetShowAnnexedSize sets whether to use annexed size of git-annex files
+func (a *ParallelAnalyzer) SetShowAnnexedSize(v bool) {
+	a.gitAnnexedSize = v
+}
+
 // GetProgressChan returns channel for getting progress
 func (a *ParallelAnalyzer) GetProgressChan() chan common.CurrentProgress {
 	return a.progressOutChan
@@ -140,7 +146,7 @@ func (a *ParallelAnalyzer) processDir(path string) *Dir {
 				continue
 			}
 			if a.followSymlinks && info.Mode()&os.ModeSymlink != 0 {
-				infoF, err := followSymlink(entryPath)
+				infoF, err := followSymlink(entryPath, a.gitAnnexedSize)
 				if err != nil {
 					log.Print(err.Error())
 					dir.Flag = '!'
@@ -219,18 +225,3 @@ func getFlag(f os.FileInfo) rune {
 	}
 	return ' '
 }
-
-func followSymlink(path string) (os.FileInfo, error) {
-	target, err := filepath.EvalSymlinks(path)
-	if err != nil {
-		return nil, err
-	}
-	tInfo, err := os.Lstat(target)
-	if err != nil {
-		return nil, err
-	}
-	if !tInfo.IsDir() {
-		return tInfo, nil
-	}
-	return nil, nil
-}
diff --git a/pkg/analyze/sequential.go b/pkg/analyze/sequential.go
index 507ebfd..acbdb13 100644
--- a/pkg/analyze/sequential.go
+++ b/pkg/analyze/sequential.go
@@ -20,6 +20,7 @@ type SequentialAnalyzer struct {
 	wait             *WaitGroup
 	ignoreDir        common.ShouldDirBeIgnored
 	followSymlinks   bool
+	gitAnnexedSize   bool
 }
 
 // CreateSeqAnalyzer returns Analyzer
@@ -42,6 +43,11 @@ func (a *SequentialAnalyzer) SetFollowSymlinks(v bool) {
 	a.followSymlinks = v
 }
 
+// SetShowAnnexedSize sets whether to use annexed size of git-annex files
+func (a *SequentialAnalyzer) SetShowAnnexedSize(v bool) {
+	a.gitAnnexedSize = v
+}
+
 // GetProgressChan returns channel for getting progress
 func (a *SequentialAnalyzer) GetProgressChan() chan common.CurrentProgress {
 	return a.progressOutChan
@@ -127,7 +133,7 @@ func (a *SequentialAnalyzer) processDir(path string) *Dir {
 				continue
 			}
 			if a.followSymlinks && info.Mode()&os.ModeSymlink != 0 {
-				infoF, err := followSymlink(entryPath)
+				infoF, err := followSymlink(entryPath, a.gitAnnexedSize)
 				if err != nil {
 					log.Print(err.Error())
 					dir.Flag = '!'
diff --git a/pkg/analyze/stored.go b/pkg/analyze/stored.go
index 2f9fde5..3b706c3 100644
--- a/pkg/analyze/stored.go
+++ b/pkg/analyze/stored.go
@@ -25,6 +25,7 @@ type StoredAnalyzer struct {
 	wait             *WaitGroup
 	ignoreDir        common.ShouldDirBeIgnored
 	followSymlinks   bool
+	gitAnnexedSize   bool
 }
 
 // CreateStoredAnalyzer returns Analyzer
@@ -57,6 +58,10 @@ func (a *StoredAnalyzer) SetFollowSymlinks(v bool) {
 	a.followSymlinks = v
 }
 
+func (a *StoredAnalyzer) SetShowAnnexedSize(v bool) {
+	a.gitAnnexedSize = v
+}
+
 // ResetProgress returns progress
 func (a *StoredAnalyzer) ResetProgress() {
 	a.progress = &common.CurrentProgress{}
diff --git a/pkg/analyze/symlink.go b/pkg/analyze/symlink.go
new file mode 100644
index 0000000..5d7abef
--- /dev/null
+++ b/pkg/analyze/symlink.go
@@ -0,0 +1,40 @@
+package analyze
+
+import (
+	"os"
+	"path/filepath"
+	"strings"
+
+	"github.com/dundee/gdu/v5/pkg/annex"
+)
+
+func followSymlink(path string, gitAnnexedSize bool) (tInfo os.FileInfo, err error) {
+	target, err := filepath.EvalSymlinks(path)
+	if err != nil {
+		target, err = os.Readlink(path)
+		if err != nil {
+			return nil, err
+		}
+		if gitAnnexedSize && strings.Contains(target, ".git/annex/objects") {
+			tInfo, err = os.Lstat(path)
+			if err != nil {
+				return nil, err
+			}
+
+			name := filepath.Base(target)
+			tInfo = annex.AnnexedFileInfo(tInfo, name)
+			return tInfo, nil
+		}
+	}
+
+	tInfo, err = os.Lstat(target)
+	if err != nil {
+		return nil, err
+	}
+
+	if tInfo.IsDir() {
+		return nil, nil
+	}
+
+	return tInfo, nil
+}
diff --git a/pkg/annex/annex.go b/pkg/annex/annex.go
new file mode 100644
index 0000000..ceda847
--- /dev/null
+++ b/pkg/annex/annex.go
@@ -0,0 +1,65 @@
+package annex
+
+import (
+	"fmt"
+	"io/fs"
+	"log"
+	"strconv"
+	"strings"
+)
+
+// SizeFromKey returns size from git-annex key.
+func SizeFromKey(name string) (int64, error) {
+	nameParts := strings.SplitN(name, "--", 2)
+	backendKVs := nameParts[0]
+	backendKVParts := strings.Split(backendKVs, "-")
+
+	if len(backendKVParts) < 2 {
+		return 0, fmt.Errorf("key is is missing backend")
+	}
+
+	for _, p := range backendKVParts[1:] {
+		if p == "" || p[0] != 's' {
+			continue
+		}
+
+		size, err := strconv.ParseInt(p[1:], 10, 64)
+		if err != nil {
+			return 0, fmt.Errorf("failed to parse size: %w", err)
+		}
+
+		return size, nil
+	}
+
+	return 0, fmt.Errorf("size not found in key")
+}
+
+// AnnexedFileInfo returns a new FileInfo with size from git-annex key.
+func AnnexedFileInfo(fi fs.FileInfo, name string) *FileInfo {
+	size, err := SizeFromKey(name)
+	if err != nil {
+		log.Print(err.Error())
+		return &FileInfo{FileInfo: fi}
+	}
+
+	afi := &FileInfo{
+		FileInfo: fi,
+		size:     size,
+	}
+
+	return afi
+}
+
+var _ fs.FileInfo = (*FileInfo)(nil)
+
+// FileInfo is a wrapper around fs.FileInfo to overwrite the size.
+type FileInfo struct {
+	fs.FileInfo
+
+	size int64
+}
+
+// Length in bytes for regular files; system-dependent for others
+func (fi *FileInfo) Size() int64 {
+	return int64(fi.size)
+}


Test patch:
diff --git a/cmd/gdu/app/app_test.go b/cmd/gdu/app/app_test.go
index 377a140..aa74337 100644
--- a/cmd/gdu/app/app_test.go
+++ b/cmd/gdu/app/app_test.go
@@ -77,6 +77,21 @@ func TestFollowSymlinks(t *testing.T) {
 	assert.Nil(t, err)
 }
 
+func TestShowAnnexedSize(t *testing.T) {
+	fin := testdir.CreateTestDir()
+	defer fin()
+
+	out, err := runApp(
+		&Flags{LogFile: "/dev/null", ShowAnnexedSize: true},
+		[]string{"test_dir"},
+		false,
+		testdev.DevicesInfoGetterMock{},
+	)
+
+	assert.Contains(t, out, "nested")
+	assert.Nil(t, err)
+}
+
 func TestAnalyzePathProfiling(t *testing.T) {
 	fin := testdir.CreateTestDir()
 	defer fin()
diff --git a/internal/common/ui_test.go b/internal/common/ui_test.go
index e669a61..c0ff88a 100644
--- a/internal/common/ui_test.go
+++ b/internal/common/ui_test.go
@@ -21,8 +21,18 @@ func TestSetFollowSymlinks(t *testing.T) {
 	assert.Equal(t, true, ui.Analyzer.(*MockedAnalyzer).FollowSymlinks)
 }
 
+func TestSetShowAnnexedSize(t *testing.T) {
+	ui := UI{
+		Analyzer: &MockedAnalyzer{},
+	}
+	ui.SetShowAnnexedSize(true)
+
+	assert.Equal(t, true, ui.Analyzer.(*MockedAnalyzer).ShowAnnexedSize)
+}
+
 type MockedAnalyzer struct {
-	FollowSymlinks bool
+	FollowSymlinks  bool
+	ShowAnnexedSize bool
 }
 
 // AnalyzeDir returns dir with files with different size exponents
@@ -51,3 +61,8 @@ func (a *MockedAnalyzer) ResetProgress() {}
 func (a *MockedAnalyzer) SetFollowSymlinks(v bool) {
 	a.FollowSymlinks = v
 }
+
+// SetShowAnnexedSize does nothing
+func (a *MockedAnalyzer) SetShowAnnexedSize(v bool) {
+	a.ShowAnnexedSize = v
+}
diff --git a/internal/testanalyze/analyze.go b/internal/testanalyze/analyze.go
index 22a8f9c..0f72aae 100644
--- a/internal/testanalyze/analyze.go
+++ b/internal/testanalyze/analyze.go
@@ -84,6 +84,9 @@ func (a *MockedAnalyzer) ResetProgress() {}
 // SetFollowSymlinks does nothing
 func (a *MockedAnalyzer) SetFollowSymlinks(v bool) {}
 
+// SetShowAnnexedSize does nothing
+func (a *MockedAnalyzer) SetShowAnnexedSize(v bool) {}
+
 // ItemFromDirWithErr returns error
 func ItemFromDirWithErr(dir, file fs.Item) error {
 	return errors.New("Failed")
diff --git a/pkg/analyze/dir_test.go b/pkg/analyze/dir_test.go
index f4de591..bd8390b 100644
--- a/pkg/analyze/dir_test.go
+++ b/pkg/analyze/dir_test.go
@@ -165,6 +165,43 @@ func TestFollowSymlink(t *testing.T) {
 	assert.Equal(t, 'e', dir.Files[1].GetFlag())
 }
 
+func TestGitAnnexSymlink(t *testing.T) {
+	fin := testdir.CreateTestDir()
+	defer fin()
+
+	err := os.Mkdir("test_dir/empty", 0o644)
+	assert.Nil(t, err)
+
+	err = os.Symlink(
+		".git/annex/objects/qx/qX/SHA256E-s967858083--"+
+			"3e54803fded8dc3a9ea68b106f7b51e04e33c79b4a7b32a860f0b22d89af5c65.mp4/SHA256E-s967858083--"+
+			"3e54803fded8dc3a9ea68b106f7b51e04e33c79b4a7b32a860f0b22d89af5c65.mp4",
+		"test_dir/nested/file3")
+	assert.Nil(t, err)
+
+	analyzer := CreateAnalyzer()
+	analyzer.SetFollowSymlinks(true)
+	analyzer.SetShowAnnexedSize(true)
+	dir := analyzer.AnalyzeDir(
+		"test_dir", func(_, _ string) bool { return false }, false,
+	).(*Dir)
+	analyzer.GetDone().Wait()
+	dir.UpdateStats(make(fs.HardLinkedItems))
+
+	sort.Sort(sort.Reverse(dir.Files))
+
+	assert.Equal(t, int64(967858083+7+4096*4), dir.Size)
+	assert.Equal(t, 7, dir.ItemCount)
+
+	// test file3
+	assert.Equal(t, "nested", dir.Files[0].GetName())
+	assert.Equal(t, "file3", dir.Files[0].(*Dir).Files[1].GetName())
+	assert.Equal(t, int64(967858083), dir.Files[0].(*Dir).Files[1].GetSize())
+	assert.Equal(t, '@', dir.Files[0].(*Dir).Files[1].GetFlag())
+
+	assert.Equal(t, 'e', dir.Files[1].GetFlag())
+}
+
 func TestBrokenSymlinkSkipped(t *testing.T) {
 	fin := testdir.CreateTestDir()
 	defer fin()
diff --git a/pkg/analyze/symlink_test.go b/pkg/analyze/symlink_test.go
new file mode 100644
index 0000000..7703365
--- /dev/null
+++ b/pkg/analyze/symlink_test.go
@@ -0,0 +1,42 @@
+package analyze
+
+import (
+	"os"
+	"testing"
+
+	"github.com/dundee/gdu/v5/internal/testdir"
+	"github.com/stretchr/testify/assert"
+)
+
+func TestFollowSymlinkErr(t *testing.T) {
+	fin := testdir.CreateTestDir()
+	defer fin()
+
+	err := os.Mkdir("test_dir/empty", 0o644)
+	assert.Nil(t, err)
+
+	err = os.Symlink(
+		".git/annex/objects/qx/qX/SHA256E-s967858083--"+
+			"3e54803fded8dc3a9ea68b106f7b51e04e33c79b4a7b32a860f0b22d89af5c65.mp4/SHA256E-s967858083--"+
+			"3e54803fded8dc3a9ea68b106f7b51e04e33c79b4a7b32a860f0b22d89af5c65.mp4",
+		"test_dir/nested/file3")
+	assert.Nil(t, err)
+
+	err = os.Symlink(
+		"test_dir/nested",
+		"test_dir/some_dir")
+	assert.Nil(t, err)
+
+	_, err = followSymlink("xxx", false)
+	assert.ErrorContains(t, err, "no such file or directory")
+
+	_, err = followSymlink("test_dir/nested/file3", false)
+	assert.ErrorContains(t, err, "no such file or directory")
+
+	_, err = followSymlink("test_dir/nested/file3", true)
+	assert.NoError(t, err)
+
+	res, err := followSymlink("test_dir/some_dir", true)
+	assert.Equal(t, nil, res)
+	assert.NoError(t, err)
+}
diff --git a/pkg/annex/annex_test.go b/pkg/annex/annex_test.go
new file mode 100644
index 0000000..5b8c072
--- /dev/null
+++ b/pkg/annex/annex_test.go
@@ -0,0 +1,39 @@
+package annex
+
+import (
+	"testing"
+
+	"github.com/stretchr/testify/assert"
+)
+
+func TestAnnexedFileInfo(t *testing.T) {
+	fi := &FileInfo{}
+	fi = AnnexedFileInfo(fi, "SHA256E-s967858083--3e54803fded8dc3a9ea68b106f7b51e04e33c79b4a7b32a860f0b22d89af5c65.mp4")
+
+	assert.Equal(t, int64(967858083), fi.Size())
+}
+
+func TestAnnexedFileInfoErr(t *testing.T) {
+	fi := &FileInfo{}
+	fi = AnnexedFileInfo(fi, "xxx")
+
+	assert.Equal(t, int64(0), fi.Size())
+}
+
+func TestSizeFromKeyErr(t *testing.T) {
+	_, err := SizeFromKey("xxx")
+	assert.Error(t, err)
+	assert.ErrorContains(t, err, "key is is missing backend")
+
+	_, err = SizeFromKey("SHA256E-sXXX--3e54803fded8dc3a9ea68b106f7b51e04e33c79b4a7b32a860f0b22d89af5c65.mp4")
+	assert.Error(t, err)
+	assert.ErrorContains(t, err, "failed to parse size")
+
+	_, err = SizeFromKey("SHA256E-s--3e54803fded8dc3a9ea68b106f7b51e04e33c79b4a7b32a860f0b22d89af5c65.mp4")
+	assert.Error(t, err)
+	assert.ErrorContains(t, err, "failed to parse size")
+
+	_, err = SizeFromKey("SHA256E-a-b-c--3e54803fded8dc3a9ea68b106f7b51e04e33c79b4a7b32a860f0b22d89af5c65.mp4")
+	assert.Error(t, err)
+	assert.ErrorContains(t, err, "size not found in key")
+}