larc r5

399 lines ยท 7.5 KB Raw
1 package status
2
3 import (
4 "database/sql"
5 "fmt"
6 "io/fs"
7 "os"
8 "path/filepath"
9 "runtime"
10 "sync"
11
12 _ "github.com/mattn/go-sqlite3"
13
14 "github.com/lain/larc/internal/core"
15 "github.com/lain/larc/internal/hash"
16 "github.com/lain/larc/internal/ignore"
17 "github.com/lain/larc/internal/repo"
18 )
19
20 /* Fast parallel scanner for working directory status.
21 * Uses mtime check first, then hash for actual comparison.
22 * Stores index in SQLite for persistence. */
23
24 // ChangeType represents the type of file change
25 type ChangeType int
26
27 const (
28 Unchanged ChangeType = iota
29 Added
30 Modified
31 Deleted
32 )
33
34 func (t ChangeType) String() string {
35 switch t {
36 case Added:
37 return "added"
38 case Modified:
39 return "modified"
40 case Deleted:
41 return "deleted"
42 default:
43 return "unchanged"
44 }
45 }
46
47 // Change represents a single file change
48 type Change struct {
49 Path string
50 Type ChangeType
51 Hash string // new hash for added/modified
52 }
53
54 // TrackedFile represents a file in the index
55 type TrackedFile struct {
56 Path string
57 BlobHash string
58 Mtime int64
59 Size int64
60 Mode uint32
61 }
62
63 // StagedFile represents a staged change
64 type StagedFile struct {
65 Path string
66 Action string // add, modify, delete
67 BlobHash string
68 Size int64
69 Mode uint32
70 }
71
72 // Scanner scans working directory for changes
73 type Scanner struct {
74 repo *repo.Repository
75 db *sql.DB
76 root string
77 indexPath string
78 ignore *ignore.Matcher
79 }
80
81 const indexSchema = `
82 CREATE TABLE IF NOT EXISTS tracked (
83 path TEXT PRIMARY KEY,
84 blob_hash TEXT,
85 mtime INTEGER,
86 size INTEGER,
87 mode INTEGER
88 );
89
90 CREATE TABLE IF NOT EXISTS staging (
91 path TEXT PRIMARY KEY,
92 action TEXT NOT NULL,
93 blob_hash TEXT,
94 size INTEGER,
95 mode INTEGER
96 );
97 `
98
99 // NewScanner creates a new scanner for the repository
100 func NewScanner(r *repo.Repository) *Scanner {
101 indexPath := filepath.Join(r.Dir, "index.db")
102
103 db, err := sql.Open("sqlite3", indexPath+"?_journal_mode=WAL&_busy_timeout=5000")
104 if err != nil {
105 return &Scanner{repo: r, root: r.Root, ignore: ignore.New(r.Root)}
106 }
107
108 db.SetMaxOpenConns(1)
109 db.Exec(indexSchema)
110
111 return &Scanner{
112 repo: r,
113 db: db,
114 root: r.Root,
115 indexPath: indexPath,
116 ignore: ignore.New(r.Root),
117 }
118 }
119
120 // Close closes the scanner
121 func (s *Scanner) Close() error {
122 if s.db != nil {
123 return s.db.Close()
124 }
125 return nil
126 }
127
128 // Scan scans working directory for changes
129 func (s *Scanner) Scan() ([]Change, error) {
130 tracked := s.loadTracked()
131 seen := make(map[string]bool)
132
133 var changes []Change
134 var mu sync.Mutex
135
136 /* parallel file walker */
137 sem := make(chan struct{}, runtime.NumCPU())
138 var wg sync.WaitGroup
139
140 err := filepath.WalkDir(s.root, func(path string, d fs.DirEntry, err error) error {
141 if err != nil {
142 return nil
143 }
144
145 relPath, err := filepath.Rel(s.root, path)
146 if err != nil {
147 return nil
148 }
149
150 /* check ignore patterns (.gitignore + .larcignore) */
151 if s.ignore.Match(relPath, d.IsDir()) {
152 if d.IsDir() {
153 return filepath.SkipDir
154 }
155 return nil
156 }
157
158 /* skip directories (we only track files) */
159 if d.IsDir() {
160 return nil
161 }
162
163 wg.Add(1)
164 go func(path, relPath string) {
165 defer wg.Done()
166 sem <- struct{}{}
167 defer func() { <-sem }()
168
169 info, err := d.Info()
170 if err != nil {
171 return
172 }
173
174 mu.Lock()
175 seen[relPath] = true
176 mu.Unlock()
177
178 tracked, exists := tracked[relPath]
179
180 if !exists {
181 /* new file */
182 h, err := hash.File(path)
183 if err != nil {
184 return
185 }
186 mu.Lock()
187 changes = append(changes, Change{
188 Path: relPath,
189 Type: Added,
190 Hash: h,
191 })
192 mu.Unlock()
193 return
194 }
195
196 /* fast path: check mtime first */
197 mtime := info.ModTime().Unix()
198 if mtime == tracked.Mtime && info.Size() == tracked.Size {
199 return // unchanged
200 }
201
202 /* slow path: compute hash */
203 h, err := hash.File(path)
204 if err != nil {
205 return
206 }
207
208 if h != tracked.BlobHash {
209 mu.Lock()
210 changes = append(changes, Change{
211 Path: relPath,
212 Type: Modified,
213 Hash: h,
214 })
215 mu.Unlock()
216 }
217 }(path, relPath)
218
219 return nil
220 })
221
222 wg.Wait()
223
224 if err != nil {
225 return nil, fmt.Errorf("walk failed: %w", err)
226 }
227
228 /* check for deleted files */
229 for path := range tracked {
230 if !seen[path] {
231 changes = append(changes, Change{
232 Path: path,
233 Type: Deleted,
234 })
235 }
236 }
237
238 return changes, nil
239 }
240
241 func (s *Scanner) loadTracked() map[string]*TrackedFile {
242 tracked := make(map[string]*TrackedFile)
243
244 if s.db == nil {
245 return tracked
246 }
247
248 rows, err := s.db.Query("SELECT path, blob_hash, mtime, size, mode FROM tracked")
249 if err != nil {
250 return tracked
251 }
252 defer rows.Close()
253
254 for rows.Next() {
255 t := &TrackedFile{}
256 if err := rows.Scan(&t.Path, &t.BlobHash, &t.Mtime, &t.Size, &t.Mode); err != nil {
257 continue
258 }
259 tracked[t.Path] = t
260 }
261
262 return tracked
263 }
264
265 // Stage stages a file for commit
266 func (s *Scanner) Stage(path, blobHash string, size int64) error {
267 if s.db == nil {
268 return nil
269 }
270
271 info, err := os.Stat(filepath.Join(s.root, path))
272 mode := uint32(0644)
273 if err == nil {
274 mode = uint32(info.Mode())
275 }
276
277 _, err = s.db.Exec(`
278 INSERT OR REPLACE INTO staging (path, action, blob_hash, size, mode)
279 VALUES (?, 'add', ?, ?, ?)
280 `, path, blobHash, size, mode)
281
282 return err
283 }
284
285 // StageDelete stages a file deletion
286 func (s *Scanner) StageDelete(path string) error {
287 if s.db == nil {
288 return nil
289 }
290
291 _, err := s.db.Exec(`
292 INSERT OR REPLACE INTO staging (path, action, blob_hash, size, mode)
293 VALUES (?, 'delete', '', 0, 0)
294 `, path)
295
296 return err
297 }
298
299 // GetStagedEntries returns all staged entries as TreeEntry slice
300 func (s *Scanner) GetStagedEntries() ([]core.TreeEntry, error) {
301 if s.db == nil {
302 return nil, nil
303 }
304
305 /* get current tree entries */
306 currentEntries := make(map[string]core.TreeEntry)
307 rev, _ := s.repo.CurrentRevision()
308 if rev > 0 {
309 r, err := s.repo.Meta.GetRevision(rev)
310 if err == nil {
311 tree, err := s.repo.GetTree(r.TreeHash)
312 if err == nil {
313 for _, e := range tree.Entries {
314 currentEntries[e.Path] = e
315 }
316 }
317 }
318 }
319
320 /* apply staged changes */
321 rows, err := s.db.Query("SELECT path, action, blob_hash, size, mode FROM staging")
322 if err != nil {
323 return nil, err
324 }
325 defer rows.Close()
326
327 for rows.Next() {
328 var path, action, blobHash string
329 var size int64
330 var mode uint32
331 if err := rows.Scan(&path, &action, &blobHash, &size, &mode); err != nil {
332 continue
333 }
334
335 if action == "delete" {
336 delete(currentEntries, path)
337 } else {
338 currentEntries[path] = core.TreeEntry{
339 Path: path,
340 Mode: mode,
341 Size: size,
342 BlobHash: blobHash,
343 Kind: core.EntryKindFile,
344 }
345 }
346 }
347
348 /* convert to slice */
349 entries := make([]core.TreeEntry, 0, len(currentEntries))
350 for _, e := range currentEntries {
351 entries = append(entries, e)
352 }
353
354 return entries, nil
355 }
356
357 // ClearStaging clears all staged changes
358 func (s *Scanner) ClearStaging() error {
359 if s.db == nil {
360 return nil
361 }
362
363 _, err := s.db.Exec("DELETE FROM staging")
364 return err
365 }
366
367 // SaveIndex saves tracked files to index
368 func (s *Scanner) SaveIndex() error {
369 if s.db == nil {
370 return nil
371 }
372
373 /* get all staged entries and update tracked table */
374 entries, err := s.GetStagedEntries()
375 if err != nil {
376 return err
377 }
378
379 tx, err := s.db.Begin()
380 if err != nil {
381 return err
382 }
383
384 for _, e := range entries {
385 info, err := os.Stat(filepath.Join(s.root, e.Path))
386 mtime := int64(0)
387 if err == nil {
388 mtime = info.ModTime().Unix()
389 }
390
391 tx.Exec(`
392 INSERT OR REPLACE INTO tracked (path, blob_hash, mtime, size, mode)
393 VALUES (?, ?, ?, ?, ?)
394 `, e.Path, e.BlobHash, mtime, e.Size, e.Mode)
395 }
396
397 return tx.Commit()
398 }
399