larc r22

439 lines ยท 8.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 "larc.wejust.rest/larc/internal/core"
15 "larc.wejust.rest/larc/internal/hash"
16 "larc.wejust.rest/larc/internal/ignore"
17 "larc.wejust.rest/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 Verbose bool
80 }
81
82 const indexSchema = `
83 CREATE TABLE IF NOT EXISTS tracked (
84 path TEXT PRIMARY KEY,
85 blob_hash TEXT,
86 mtime INTEGER,
87 size INTEGER,
88 mode INTEGER
89 );
90
91 CREATE TABLE IF NOT EXISTS staging (
92 path TEXT PRIMARY KEY,
93 action TEXT NOT NULL,
94 blob_hash TEXT,
95 size INTEGER,
96 mode INTEGER
97 );
98 `
99
100 // NewScanner creates a new scanner for the repository
101 func NewScanner(r *repo.Repository) *Scanner {
102 indexPath := filepath.Join(r.Dir, "index.db")
103
104 db, err := sql.Open("sqlite3", indexPath+"?_journal_mode=WAL&_busy_timeout=5000")
105 if err != nil {
106 return &Scanner{repo: r, root: r.Root, ignore: ignore.New(r.Root)}
107 }
108
109 db.SetMaxOpenConns(1)
110 db.Exec(indexSchema)
111
112 return &Scanner{
113 repo: r,
114 db: db,
115 root: r.Root,
116 indexPath: indexPath,
117 ignore: ignore.New(r.Root),
118 }
119 }
120
121 // Close closes the scanner
122 func (s *Scanner) Close() error {
123 if s.db != nil {
124 return s.db.Close()
125 }
126 return nil
127 }
128
129 // Scan scans working directory for changes
130 func (s *Scanner) Scan() ([]Change, error) {
131 if s.Verbose {
132 fmt.Println("loading tracked...")
133 }
134 tracked := s.loadTracked()
135 if s.Verbose {
136 fmt.Printf("%d tracked files\n", len(tracked))
137 }
138 seen := make(map[string]bool)
139
140 var changes []Change
141 var mu sync.Mutex
142 var fileCount int
143
144 /* parallel file walker */
145 sem := make(chan struct{}, runtime.NumCPU())
146 var wg sync.WaitGroup
147
148 if s.Verbose {
149 fmt.Println("scanning working directory...")
150 }
151 err := filepath.WalkDir(s.root, func(path string, d fs.DirEntry, err error) error {
152 if err != nil {
153 return nil
154 }
155
156 relPath, err := filepath.Rel(s.root, path)
157 if err != nil {
158 return nil
159 }
160
161 /* check ignore patterns (.gitignore + .larcignore) */
162 if s.ignore.Match(relPath, d.IsDir()) {
163 if d.IsDir() {
164 return filepath.SkipDir
165 }
166 return nil
167 }
168
169 /* skip directories (we only track files) */
170 if d.IsDir() {
171 return nil
172 }
173
174 fileCount++
175 if fileCount%100 == 0 {
176 fmt.Printf("DEBUG scan: walked %d files...\n", fileCount)
177 }
178
179 /* get file info BEFORE launching goroutine to avoid race condition */
180 info, err := d.Info()
181 if err != nil {
182 return nil
183 }
184
185 wg.Add(1)
186 go func(path, relPath string, info fs.FileInfo) {
187 defer wg.Done()
188 sem <- struct{}{}
189 defer func() { <-sem }()
190
191 mu.Lock()
192 seen[relPath] = true
193 mu.Unlock()
194
195 tracked, exists := tracked[relPath]
196
197 if !exists {
198 /* new file */
199 h, err := hash.File(path)
200 if err != nil {
201 return
202 }
203 mu.Lock()
204 changes = append(changes, Change{
205 Path: relPath,
206 Type: Added,
207 Hash: h,
208 })
209 mu.Unlock()
210 return
211 }
212
213 /* fast path: check mtime first */
214 mtime := info.ModTime().Unix()
215 if mtime == tracked.Mtime && info.Size() == tracked.Size {
216 return // unchanged
217 }
218
219 /* slow path: compute hash */
220 h, err := hash.File(path)
221 if err != nil {
222 return
223 }
224
225 if h != tracked.BlobHash {
226 mu.Lock()
227 changes = append(changes, Change{
228 Path: relPath,
229 Type: Modified,
230 Hash: h,
231 })
232 mu.Unlock()
233 }
234 }(path, relPath, info)
235
236 return nil
237 })
238
239 if s.Verbose {
240 fmt.Printf("walked %d files\n", fileCount)
241 }
242 wg.Wait()
243
244 if err != nil {
245 return nil, fmt.Errorf("walk failed: %w", err)
246 }
247
248 /* check for deleted files */
249 for path := range tracked {
250 if !seen[path] {
251 changes = append(changes, Change{
252 Path: path,
253 Type: Deleted,
254 })
255 }
256 }
257
258 return changes, nil
259 }
260
261 func (s *Scanner) loadTracked() map[string]*TrackedFile {
262 tracked := make(map[string]*TrackedFile)
263
264 if s.db == nil {
265 return tracked
266 }
267
268 rows, err := s.db.Query("SELECT path, blob_hash, mtime, size, mode FROM tracked")
269 if err != nil {
270 return tracked
271 }
272 defer rows.Close()
273
274 for rows.Next() {
275 t := &TrackedFile{}
276 if err := rows.Scan(&t.Path, &t.BlobHash, &t.Mtime, &t.Size, &t.Mode); err != nil {
277 continue
278 }
279 tracked[t.Path] = t
280 }
281
282 return tracked
283 }
284
285 // Stage stages a file for commit
286 func (s *Scanner) Stage(path, blobHash string, size int64) error {
287 if s.db == nil {
288 return nil
289 }
290
291 info, err := os.Stat(filepath.Join(s.root, path))
292 mode := uint32(0644)
293 if err == nil {
294 mode = uint32(info.Mode())
295 }
296
297 _, err = s.db.Exec(`
298 INSERT OR REPLACE INTO staging (path, action, blob_hash, size, mode)
299 VALUES (?, 'add', ?, ?, ?)
300 `, path, blobHash, size, mode)
301
302 return err
303 }
304
305 // StageDelete stages a file deletion
306 func (s *Scanner) StageDelete(path string) error {
307 if s.db == nil {
308 return nil
309 }
310
311 _, err := s.db.Exec(`
312 INSERT OR REPLACE INTO staging (path, action, blob_hash, size, mode)
313 VALUES (?, 'delete', '', 0, 0)
314 `, path)
315
316 return err
317 }
318
319 // GetStagedEntries returns all staged entries as TreeEntry slice
320 func (s *Scanner) GetStagedEntries() ([]core.TreeEntry, error) {
321 if s.db == nil {
322 return nil, nil
323 }
324
325 /* get current tree entries */
326 currentEntries := make(map[string]core.TreeEntry)
327 rev, _ := s.repo.CurrentRevision()
328 if rev > 0 {
329 r, err := s.repo.Meta.GetRevision(rev)
330 if err == nil {
331 tree, err := s.repo.GetTree(r.TreeHash)
332 if err == nil {
333 for _, e := range tree.Entries {
334 currentEntries[e.Path] = e
335 }
336 }
337 }
338 }
339
340 /* apply staged changes */
341 rows, err := s.db.Query("SELECT path, action, blob_hash, size, mode FROM staging")
342 if err != nil {
343 return nil, err
344 }
345 defer rows.Close()
346
347 for rows.Next() {
348 var path, action, blobHash string
349 var size int64
350 var mode uint32
351 if err := rows.Scan(&path, &action, &blobHash, &size, &mode); err != nil {
352 continue
353 }
354
355 if action == "delete" {
356 delete(currentEntries, path)
357 } else {
358 currentEntries[path] = core.TreeEntry{
359 Path: path,
360 Mode: mode,
361 Size: size,
362 BlobHash: blobHash,
363 Kind: core.EntryKindFile,
364 }
365 }
366 }
367
368 /* convert to slice */
369 entries := make([]core.TreeEntry, 0, len(currentEntries))
370 for _, e := range currentEntries {
371 entries = append(entries, e)
372 }
373
374 return entries, nil
375 }
376
377 // ClearStaging clears all staged changes
378 func (s *Scanner) ClearStaging() error {
379 if s.db == nil {
380 return nil
381 }
382
383 _, err := s.db.Exec("DELETE FROM staging")
384 return err
385 }
386
387 // SaveIndex saves tracked files to index
388 func (s *Scanner) SaveIndex() error {
389 if s.db == nil {
390 return nil
391 }
392
393 /* collect deletions BEFORE starting transaction */
394 var deletePaths []string
395 rows, err := s.db.Query("SELECT path FROM staging WHERE action = 'delete'")
396 if err == nil {
397 for rows.Next() {
398 var path string
399 if err := rows.Scan(&path); err == nil {
400 deletePaths = append(deletePaths, path)
401 }
402 }
403 rows.Close()
404 }
405
406 /* get all staged entries BEFORE starting transaction */
407 entries, err := s.GetStagedEntries()
408 if err != nil {
409 return err
410 }
411
412 /* now start transaction and apply changes */
413 tx, err := s.db.Begin()
414 if err != nil {
415 return err
416 }
417
418 /* handle deletions */
419 for _, path := range deletePaths {
420 tx.Exec("DELETE FROM tracked WHERE path = ?", path)
421 }
422
423 /* update tracked table */
424 for _, e := range entries {
425 info, err := os.Stat(filepath.Join(s.root, e.Path))
426 mtime := int64(0)
427 if err == nil {
428 mtime = info.ModTime().Unix()
429 }
430
431 tx.Exec(`
432 INSERT OR REPLACE INTO tracked (path, blob_hash, mtime, size, mode)
433 VALUES (?, ?, ?, ?, ?)
434 `, e.Path, e.BlobHash, mtime, e.Size, e.Mode)
435 }
436
437 return tx.Commit()
438 }
439