Skip to content

Commit 08c0b8b

Browse files
added efficient scanning with scalable resources allocation
1 parent 73fc1a3 commit 08c0b8b

7 files changed

Lines changed: 266 additions & 99 deletions

File tree

.gitignore

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,3 +24,4 @@ vendor/
2424
# Swap files
2525
*.swp
2626
*.swo
27+
website/

README.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@
77
<a href="https://github.com/PatchMon/lintree/releases"><img src="https://img.shields.io/github/v/release/PatchMon/lintree?style=flat-square&color=00b4d8" alt="Release"></a>
88
<a href="https://github.com/PatchMon/lintree/actions"><img src="https://img.shields.io/github/actions/workflow/status/PatchMon/lintree/ci.yml?style=flat-square" alt="CI"></a>
99
<a href="LICENSE"><img src="https://img.shields.io/badge/license-MIT-blue?style=flat-square" alt="License"></a>
10+
<a href="https://lintree.sh"><img src="https://img.shields.io/badge/Website-lintree.sh-00b4d8?style=flat-square" alt="Website"></a>
1011
<a href="https://buymeacoffee.com/iby___"><img src="https://img.shields.io/badge/Buy%20Me%20a%20Coffee-ffdd00?style=flat-square&logo=buy-me-a-coffee&logoColor=black" alt="Buy Me A Coffee"></a>
1112
</p>
1213
</p>

go.mod

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2,13 +2,15 @@ module lintree
22

33
go 1.26.1
44

5-
require github.com/gdamore/tcell/v2 v2.13.8
5+
require (
6+
github.com/gdamore/tcell/v2 v2.13.8
7+
golang.org/x/sys v0.38.0
8+
)
69

710
require (
811
github.com/gdamore/encoding v1.0.1 // indirect
912
github.com/lucasb-eyer/go-colorful v1.3.0 // indirect
1013
github.com/rivo/uniseg v0.4.7 // indirect
11-
golang.org/x/sys v0.38.0 // indirect
1214
golang.org/x/term v0.37.0 // indirect
1315
golang.org/x/text v0.31.0 // indirect
1416
)

internal/scanner/node.go

Lines changed: 3 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,6 @@ import (
44
"fmt"
55
"sort"
66
"strings"
7-
"sync"
87
)
98

109
// FileNode represents a file or directory in the scanned tree.
@@ -16,8 +15,7 @@ type FileNode struct {
1615
Parent *FileNode
1716
FileCount int64
1817
DirCount int64
19-
Err error
20-
mu sync.Mutex // protects Children append during concurrent scan
18+
Err error
2119
}
2220

2321
// Path computes the full path by walking up the parent chain.
@@ -41,11 +39,10 @@ func (n *FileNode) Path() string {
4139
return strings.Join(parts, "/")
4240
}
4341

44-
// addChild safely appends a child during concurrent scanning.
42+
// addChild appends a child node. Safe because each directory is processed
43+
// by exactly one goroutine.
4544
func (n *FileNode) addChild(child *FileNode) {
46-
n.mu.Lock()
4745
n.Children = append(n.Children, child)
48-
n.mu.Unlock()
4946
}
5047

5148
// computeSizes walks bottom-up to compute aggregate Size/FileCount/DirCount.

internal/scanner/scan_unix.go

Lines changed: 148 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,148 @@
1+
//go:build !windows
2+
3+
package scanner
4+
5+
import (
6+
"context"
7+
"os"
8+
9+
"golang.org/x/sys/unix"
10+
)
11+
12+
func processDir(ctx context.Context, node *FileNode, dirPath string, st *scanState, pathBuf []byte) {
13+
defer st.wg.Done()
14+
15+
select {
16+
case <-ctx.Done():
17+
return
18+
default:
19+
}
20+
21+
if skipDirs[dirPath] {
22+
return
23+
}
24+
25+
entries, err := os.ReadDir(dirPath)
26+
if err != nil {
27+
node.Err = err
28+
return
29+
}
30+
31+
st.dirsScanned.Add(1)
32+
st.currentPath.Store(dirPath)
33+
34+
// Open directory fd for Fstatat — avoids kernel re-resolving the full path per file.
35+
dirfd, err := unix.Open(dirPath, unix.O_RDONLY|unix.O_DIRECTORY|unix.O_CLOEXEC, 0)
36+
if err != nil {
37+
// Fall back to entry.Info() if we can't open the dirfd.
38+
processDirFallback(ctx, node, dirPath, entries, st, pathBuf)
39+
return
40+
}
41+
defer unix.Close(dirfd)
42+
43+
// Pre-allocate children slice to avoid repeated growth.
44+
if cap(node.Children) == 0 {
45+
node.Children = make([]*FileNode, 0, len(entries))
46+
}
47+
48+
for _, entry := range entries {
49+
select {
50+
case <-ctx.Done():
51+
return
52+
default:
53+
}
54+
55+
if entry.Type()&os.ModeSymlink != 0 {
56+
continue
57+
}
58+
59+
name := entry.Name()
60+
61+
// Build child path using reusable buffer to avoid filepath.Join allocation.
62+
pathBuf = appendPath(pathBuf[:0], dirPath, name)
63+
childPath := string(pathBuf)
64+
65+
child := &FileNode{
66+
Name: name,
67+
IsDir: entry.IsDir(),
68+
Parent: node,
69+
}
70+
71+
if entry.IsDir() {
72+
node.addChild(child)
73+
st.wg.Add(1)
74+
select {
75+
case st.workCh <- work{node: child, path: childPath}:
76+
default:
77+
processDir(ctx, child, childPath, st, pathBuf)
78+
}
79+
} else {
80+
// Use Fstatat with the directory fd — single syscall, no path re-resolution.
81+
var stat unix.Stat_t
82+
if err := unix.Fstatat(dirfd, name, &stat, unix.AT_SYMLINK_NOFOLLOW); err == nil {
83+
child.Size = stat.Blocks * 512
84+
} else {
85+
child.Err = err
86+
}
87+
st.filesFound.Add(1)
88+
st.bytesFound.Add(child.Size)
89+
node.addChild(child)
90+
}
91+
}
92+
}
93+
94+
// processDirFallback handles the case where we can't open a dirfd (e.g. permission issues).
95+
func processDirFallback(ctx context.Context, node *FileNode, dirPath string, entries []os.DirEntry, st *scanState, pathBuf []byte) {
96+
if cap(node.Children) == 0 {
97+
node.Children = make([]*FileNode, 0, len(entries))
98+
}
99+
100+
for _, entry := range entries {
101+
select {
102+
case <-ctx.Done():
103+
return
104+
default:
105+
}
106+
107+
if entry.Type()&os.ModeSymlink != 0 {
108+
continue
109+
}
110+
111+
name := entry.Name()
112+
pathBuf = appendPath(pathBuf[:0], dirPath, name)
113+
childPath := string(pathBuf)
114+
115+
child := &FileNode{
116+
Name: name,
117+
IsDir: entry.IsDir(),
118+
Parent: node,
119+
}
120+
121+
if entry.IsDir() {
122+
node.addChild(child)
123+
st.wg.Add(1)
124+
select {
125+
case st.workCh <- work{node: child, path: childPath}:
126+
default:
127+
processDir(ctx, child, childPath, st, pathBuf)
128+
}
129+
} else {
130+
if info, err := entry.Info(); err == nil {
131+
child.Size = fileSize(info)
132+
} else {
133+
child.Err = err
134+
}
135+
st.filesFound.Add(1)
136+
st.bytesFound.Add(child.Size)
137+
node.addChild(child)
138+
}
139+
}
140+
}
141+
142+
// appendPath builds dirPath + "/" + name into buf without allocating.
143+
func appendPath(buf []byte, dir, name string) []byte {
144+
buf = append(buf, dir...)
145+
buf = append(buf, '/')
146+
buf = append(buf, name...)
147+
return buf
148+
}

internal/scanner/scan_windows.go

Lines changed: 74 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,74 @@
1+
//go:build windows
2+
3+
package scanner
4+
5+
import (
6+
"context"
7+
"os"
8+
"path/filepath"
9+
)
10+
11+
func processDir(ctx context.Context, node *FileNode, dirPath string, st *scanState, pathBuf []byte) {
12+
defer st.wg.Done()
13+
14+
select {
15+
case <-ctx.Done():
16+
return
17+
default:
18+
}
19+
20+
if skipDirs[dirPath] {
21+
return
22+
}
23+
24+
entries, err := os.ReadDir(dirPath)
25+
if err != nil {
26+
node.Err = err
27+
return
28+
}
29+
30+
st.dirsScanned.Add(1)
31+
st.currentPath.Store(dirPath)
32+
33+
if cap(node.Children) == 0 {
34+
node.Children = make([]*FileNode, 0, len(entries))
35+
}
36+
37+
for _, entry := range entries {
38+
select {
39+
case <-ctx.Done():
40+
return
41+
default:
42+
}
43+
44+
if entry.Type()&os.ModeSymlink != 0 {
45+
continue
46+
}
47+
48+
childPath := filepath.Join(dirPath, entry.Name())
49+
child := &FileNode{
50+
Name: entry.Name(),
51+
IsDir: entry.IsDir(),
52+
Parent: node,
53+
}
54+
55+
if entry.IsDir() {
56+
node.addChild(child)
57+
st.wg.Add(1)
58+
select {
59+
case st.workCh <- work{node: child, path: childPath}:
60+
default:
61+
processDir(ctx, child, childPath, st, pathBuf)
62+
}
63+
} else {
64+
if info, err := entry.Info(); err == nil {
65+
child.Size = fileSize(info)
66+
} else {
67+
child.Err = err
68+
}
69+
st.filesFound.Add(1)
70+
st.bytesFound.Add(child.Size)
71+
node.addChild(child)
72+
}
73+
}
74+
}

0 commit comments

Comments
 (0)