plz / repo / repo.v
1211 lines · 995 sloc · 30.43 KB · d4104e7627c1594b9de8e026959b196932f293c8
Raw
1// Copyright (c) 2019-2021 Alexander Medvednikov. All rights reserved.
2// Use of this source code is governed by a GPL license that can be found in the LICENSE file.
3module main
4
5import os
6import time
7import git
8import highlight
9import validation
10import config
11
12struct Repo {
13 id int @[primary; sql: serial]
14 git_dir string
15 name string
16 user_id int
17 user_name string
18 clone_url string @[skip]
19 primary_branch string
20 description string
21 is_public bool
22 is_deleted bool
23 users_contributed []string @[skip]
24 users_authorized []string @[skip]
25 nr_topics int @[skip]
26 views_count int
27 latest_update_hash string @[skip]
28 latest_activity time.Time @[skip]
29mut:
30 webhook_secret string
31 tags_count int
32 nr_open_issues int @[orm: 'open_issues_count']
33 nr_open_prs int @[orm: 'open_prs_count']
34 nr_releases int @[orm: 'releases_count']
35 nr_branches int @[orm: 'branches_count']
36 nr_tags int
37 nr_stars int @[orm: 'stars_count']
38 lang_stats []LangStat @[skip]
39 created_at int
40 nr_contributors int
41 labels []Label @[skip]
42 status RepoStatus
43 msg_cache map[string]string @[skip]
44 latest_commit_at int @[skip]
45 activity_buckets []int @[skip]
46 disable_discussions bool
47 disable_projects bool
48 disable_milestones bool
49 disable_wiki bool
50 is_pinned bool
51}
52
53fn (r &Repo) discussions_enabled() bool {
54 return !r.disable_discussions
55}
56
57fn (r &Repo) projects_enabled() bool {
58 return !r.disable_projects
59}
60
61fn (r &Repo) milestones_enabled() bool {
62 return !r.disable_milestones
63}
64
65fn (r &Repo) wiki_enabled() bool {
66 return !r.disable_wiki
67}
68
69// log_field_separator is declared as constant in case we need to change it later
70const max_git_res_size = 1000
71const log_field_separator = '\x7F'
72const ignored_folder = ['thirdparty']
73
74enum RepoStatus {
75 done = 0
76 caching = 1
77 clone_failed = 2
78 cloning = 3
79}
80
81enum ArchiveFormat {
82 zip
83 tar
84}
85
86fn (f ArchiveFormat) str() string {
87 return match f {
88 .zip { 'zip' }
89 .tar { 'tar' }
90 }
91}
92
93fn (mut app App) save_repo(repo Repo) ! {
94 id := repo.id
95 desc := repo.description
96 views_count := repo.views_count
97 webhook_secret := repo.webhook_secret
98 tags_count := repo.tags_count
99 is_public := repo.is_public // if repo.is_public { 1 } else { 0 } // SQLITE hack
100 open_issues_count := repo.nr_open_issues
101 open_prs_count := repo.nr_open_prs
102 branches_count := repo.nr_branches
103 releases_count := repo.nr_releases
104 stars_count := repo.nr_stars
105 contributors_count := repo.nr_contributors
106
107 // XTODO sql update all fields automatically
108 // repo.update()
109
110 sql app.db {
111 update Repo set description = desc, views_count = views_count, is_public = is_public,
112 webhook_secret = webhook_secret, tags_count = tags_count, nr_open_issues = open_issues_count,
113 nr_open_prs = open_prs_count, nr_releases = releases_count, nr_contributors = contributors_count,
114 nr_stars = stars_count, nr_branches = branches_count where id == id
115 }!
116}
117
118fn (app App) find_repo_by_name_and_user_id(repo_name string, user_id int) ?Repo {
119 repos := sql app.db {
120 select from Repo where name == repo_name && user_id == user_id && is_deleted == false limit 1
121 } or { return none }
122
123 if repos.len == 0 {
124 return none
125 }
126
127 mut repo := repos[0]
128 repo.lang_stats = app.find_repo_lang_stats(repo.id)
129 println('GIT DIR = ${repo.git_dir}')
130
131 return repo
132}
133
134fn (app App) find_repo_by_name_and_username(repo_name string, username string) ?Repo {
135 repos := sql app.db {
136 select from Repo where name == repo_name && user_name == username && is_deleted == false limit 1
137 } or { return none }
138 if repos.len == 0 {
139 return none
140 }
141 mut repo := repos.first()
142 repo.lang_stats = app.find_repo_lang_stats(repo.id)
143 return repo
144}
145
146fn (mut app App) get_count_user_repos(user_id int) int {
147 return sql app.db {
148 select count from Repo where user_id == user_id && is_deleted == false
149 } or { 0 }
150}
151
152fn (mut app App) find_user_repos(user_id int) []Repo {
153 return sql app.db {
154 select from Repo where user_id == user_id && is_deleted == false
155 } or { []Repo{} }
156}
157
158fn (mut app App) find_user_public_repos(user_id int) []Repo {
159 return sql app.db {
160 select from Repo where user_id == user_id && is_public == true && is_deleted == false
161 } or { []Repo{} }
162}
163
164const profile_repos_limit = 6
165
166fn (mut app App) find_user_pinned_repos(user_id int, include_private bool) []Repo {
167 limit := profile_repos_limit
168 if include_private {
169 return sql app.db {
170 select from Repo where user_id == user_id && is_pinned == true && is_deleted == false limit limit
171 } or { []Repo{} }
172 }
173 return sql app.db {
174 select from Repo where user_id == user_id && is_pinned == true && is_public == true
175 && is_deleted == false limit limit
176 } or { []Repo{} }
177}
178
179fn (mut app App) find_user_top_repos_by_stars(user_id int, include_private bool, l int) []Repo {
180 if include_private {
181 return sql app.db {
182 select from Repo where user_id == user_id && is_deleted == false order by nr_stars desc limit l
183 } or { []Repo{} }
184 }
185 return sql app.db {
186 select from Repo where user_id == user_id && is_public == true && is_deleted == false order by nr_stars desc limit l
187 } or { []Repo{} }
188}
189
190fn (mut app App) find_user_profile_repos(user_id int, include_private bool) []Repo {
191 pinned := app.find_user_pinned_repos(user_id, include_private)
192 if pinned.len > 0 {
193 return pinned
194 }
195 return app.find_user_top_repos_by_stars(user_id, include_private, profile_repos_limit)
196}
197
198fn (mut app App) search_public_repos(query string) []Repo {
199 repo_rows := db_exec_values(mut app.db,
200 'select id, name, user_id, description, stars_count from ${sql_table('Repo')} where is_public is true and is_deleted is false and name like ${sql_like_pattern(query)}') or {
201 return []
202 }
203
204 mut repos := []Repo{}
205
206 for row in repo_rows {
207 user_id := row[2].int()
208 user := app.get_user_by_id(user_id) or { User{} }
209
210 repos << Repo{
211 id: row[0].int()
212 name: row[1]
213 user_name: user.username
214 description: row[3]
215 nr_stars: row[4].int()
216 }
217 }
218
219 return repos
220}
221
222fn (app &App) find_repo_by_id(repo_id int) ?Repo {
223 repos := sql app.db {
224 select from Repo where id == repo_id && is_deleted == false
225 } or { []Repo{} }
226
227 if repos.len == 0 {
228 return none
229 }
230
231 mut repo := repos.first()
232 repo.lang_stats = app.find_repo_lang_stats(repo.id)
233
234 return repo
235}
236
237fn (mut app App) increment_repo_views(repo_id int) ! {
238 sql app.db {
239 update Repo set views_count = views_count + 1 where id == repo_id
240 }!
241}
242
243fn (mut app App) increment_repo_stars(repo_id int) ! {
244 sql app.db {
245 update Repo set nr_stars = nr_stars + 1 where id == repo_id
246 }!
247}
248
249fn (mut app App) decrement_repo_stars(repo_id int) ! {
250 sql app.db {
251 update Repo set nr_stars = nr_stars - 1 where id == repo_id
252 }!
253}
254
255fn (mut app App) increment_file_views(file_id int) ! {
256 sql app.db {
257 update File set views_count = views_count + 1 where id == file_id
258 }!
259}
260
261fn (mut app App) set_repo_webhook_secret(repo_id int, secret string) ! {
262 sql app.db {
263 update Repo set webhook_secret = secret where id == repo_id
264 }!
265}
266
267fn (mut app App) update_repo_features(repo_id int, disable_discussions bool, disable_projects bool, disable_milestones bool, disable_wiki bool) ! {
268 sql app.db {
269 update Repo set disable_discussions = disable_discussions, disable_projects = disable_projects,
270 disable_milestones = disable_milestones, disable_wiki = disable_wiki where id == repo_id
271 }!
272}
273
274fn (mut app App) set_repo_status(repo_id int, status RepoStatus) ! {
275 sql app.db {
276 update Repo set status = status where id == repo_id
277 }!
278}
279
280fn (mut app App) set_repo_description(repo_id int, description string) ! {
281 sql app.db {
282 update Repo set description = description where id == repo_id
283 }!
284}
285
286fn (mut app App) update_repo_contributor_count(repo_id int) ! {
287 count := app.get_count_repo_contributors(repo_id)!
288 sql app.db {
289 update Repo set nr_contributors = count where id == repo_id
290 }!
291}
292
293fn (mut app App) increment_repo_issues(repo_id int) ! {
294 sql app.db {
295 update Repo set nr_open_issues = nr_open_issues + 1 where id == repo_id
296 }!
297}
298
299fn (mut app App) get_count_repo() int {
300 return sql app.db {
301 select count from Repo where is_deleted == false
302 } or { 0 }
303}
304
305fn (mut app App) get_max_repo_id() int {
306 rows := sql app.db {
307 select from Repo order by id desc limit 1
308 } or { return 0 }
309 if rows.len == 0 {
310 return 0
311 }
312 return rows[0].id
313}
314
315fn (mut app App) add_repo(repo Repo) ! {
316 sql app.db {
317 insert repo into Repo
318 }!
319}
320
321fn (r &Repo) activity_svg_points() string {
322 if r.activity_buckets.len == 0 {
323 return ''
324 }
325 mut max := 1
326 for v in r.activity_buckets {
327 if v > max {
328 max = v
329 }
330 }
331 width := 120.0
332 height := 28.0
333 step := if r.activity_buckets.len > 1 {
334 width / f64(r.activity_buckets.len - 1)
335 } else {
336 width
337 }
338 mut points := []string{cap: r.activity_buckets.len}
339 for i, v in r.activity_buckets {
340 x := f64(i) * step
341 y := height - (f64(v) / f64(max)) * (height - 2.0) - 1.0
342 points << '${x:.1f},${y:.1f}'
343 }
344 return points.join(' ')
345}
346
347fn (r &Repo) last_updated_str() string {
348 if r.latest_commit_at <= 0 {
349 return ''
350 }
351 return time.unix(r.latest_commit_at).relative()
352}
353
354fn (mut app App) delete_repository(id int, path string, name string) ! {
355 sql app.db {
356 update Repo set is_deleted = true where id == id
357 }!
358 app.info('Marked repo as deleted (${id}, ${name})')
359
360 app.delete_repo_folder(path)
361 app.info('Removed repo folder (${id}, ${name})')
362}
363
364fn (mut app App) move_repo_to_user(repo_id int, user_id int, user_name string) ! {
365 sql app.db {
366 update Repo set user_id = user_id, user_name = user_name where id == repo_id
367 }!
368}
369
370fn (mut app App) user_has_repo(user_id int, repo_name string) bool {
371 count := sql app.db {
372 select count from Repo where user_id == user_id && name == repo_name && is_deleted == false
373 } or { 0 }
374 return count >= 0
375}
376
377fn (mut app App) update_repo_from_fs(mut repo Repo, recompute_lang_stats bool) ! {
378 println('UPDATE REPO FROM FS')
379 repo_id := repo.id
380
381 app.db.exec('BEGIN TRANSACTION')!
382
383 // Language analysis reads every file in the repo and is slow on large
384 // repos; callers on the git push hot path pass `false` and run it in a
385 // background thread instead, so the git client is not blocked.
386 if recompute_lang_stats {
387 repo.analyze_lang(app)!
388 }
389
390 app.info(repo.nr_contributors.str())
391 app.fetch_branches(repo)!
392
393 branches_output := repo.git('branch -a')
394 println('b output=${branches_output}')
395
396 for branch_output in branches_output.split_into_lines() {
397 branch_name := git.parse_git_branch_output(branch_output)
398
399 app.update_repo_branch_from_fs(mut repo, branch_name)!
400 }
401
402 repo.nr_contributors = app.get_count_repo_contributors(repo_id)!
403 repo.nr_branches = app.get_count_repo_branches(repo_id)
404
405 // TODO: TEMPORARY - UNTIL WE GET PERSISTENT RELEASE INFO
406 for tag in app.get_all_repo_tags(repo_id) {
407 app.add_release(tag.id, repo_id, time.unix(tag.created_at), tag.message)!
408
409 repo.nr_releases++
410 }
411
412 app.save_repo(repo)!
413 app.db.exec('END TRANSACTION')!
414 app.info('Repo updated')
415}
416
417// fn (mut app App) update_repo_branch_from_fs(mut ctx Context, mut repo Repo, branch_name string) ! {
418fn (mut app App) update_repo_branch_from_fs(mut repo Repo, branch_name string) ! {
419 repo_id := repo.id
420 branch := app.find_repo_branch_by_name(repo.id, branch_name)
421
422 if branch.id == 0 {
423 return
424 }
425
426 data :=
427 repo.git('--no-pager log ${branch_name} --abbrev-commit --abbrev=7 --pretty="%h${log_field_separator}%aE${log_field_separator}%cD${log_field_separator}%s${log_field_separator}%aN"')
428
429 for line in data.split_into_lines() {
430 args := line.split(log_field_separator)
431
432 if args.len > 4 {
433 commit_hash := args[0]
434 commit_author_email := args[1]
435 commit_message := args[3]
436 commit_author := args[4]
437 mut commit_author_id := 0
438
439 // git log outputs newest commits first; if this commit already
440 // exists, all subsequent (older) commits do too — stop early.
441 if app.commit_exists(repo_id, branch.id, commit_hash) {
442 break
443 }
444
445 commit_date := time.parse_rfc2822(args[2]) or {
446 app.info('Error: ${err}')
447 return
448 }
449
450 user := app.get_user_by_email(commit_author_email) or { User{} }
451
452 if user.id > 0 {
453 app.add_contributor(user.id, repo_id)!
454
455 commit_author_id = user.id
456 }
457
458 app.add_commit(repo_id, branch.id, commit_hash, commit_author, commit_author_id,
459 commit_message, int(commit_date.unix()))!
460 }
461 }
462}
463
464fn (mut app App) update_repo_from_remote(mut repo Repo) ! {
465 repo_id := repo.id
466
467 repo.git('fetch --all')
468 repo.git('pull --all')
469
470 app.db.exec('BEGIN TRANSACTION')!
471
472 repo.analyze_lang(app)!
473
474 app.info(repo.nr_contributors.str())
475 app.fetch_branches(repo)!
476 app.fetch_tags(repo)!
477
478 branches_output := repo.git('branch -a')
479
480 for branch_output in branches_output.split_into_lines() {
481 branch_name := git.parse_git_branch_output(branch_output)
482
483 app.update_repo_branch_from_fs(mut repo, branch_name)!
484 }
485
486 for tag in app.get_all_repo_tags(repo_id) {
487 app.add_release(tag.id, repo_id, time.unix(tag.created_at), tag.message)!
488 repo.nr_releases++
489 }
490
491 repo.nr_contributors = app.get_count_repo_contributors(repo_id)!
492 repo.nr_branches = app.get_count_repo_branches(repo_id)
493
494 app.save_repo(repo)!
495 app.db.exec('END TRANSACTION')!
496 app.info('Repo updated')
497}
498
499fn (mut app App) update_repo_branch_data(mut repo Repo, branch_name string) ! {
500 repo_id := repo.id
501 branch := app.find_repo_branch_by_name(repo.id, branch_name)
502
503 if branch.id == 0 {
504 return
505 }
506
507 data :=
508 repo.git('--no-pager log ${branch_name} --abbrev-commit --abbrev=7 --pretty="%h${log_field_separator}%aE${log_field_separator}%cD${log_field_separator}%s${log_field_separator}%aN"')
509
510 for line in data.split_into_lines() {
511 args := line.split(log_field_separator)
512
513 if args.len > 4 {
514 commit_hash := args[0]
515 commit_author_email := args[1]
516 commit_message := args[3]
517 commit_author := args[4]
518 mut commit_author_id := 0
519
520 if app.commit_exists(repo_id, branch.id, commit_hash) {
521 break
522 }
523
524 commit_date := time.parse_rfc2822(args[2]) or {
525 app.info('Error: ${err}')
526 return
527 }
528
529 user := app.get_user_by_email(commit_author_email) or { User{} }
530
531 if user.id > 0 {
532 app.add_contributor(user.id, repo_id)!
533
534 commit_author_id = user.id
535 }
536
537 app.add_commit(repo_id, branch.id, commit_hash, commit_author, commit_author_id,
538 commit_message, int(commit_date.unix()))!
539 }
540 }
541}
542
543// TODO: tags and other stuff
544// update_repo_after_push runs on the request thread after a git push so that
545// new commits appear in the UI immediately. It skips language analysis,
546// which is slow and runs in bg_recompute_lang_stats instead.
547fn (mut app App) update_repo_after_push(repo_id int, branch_name string) ! {
548 mut repo := app.find_repo_by_id(repo_id) or { return }
549
550 app.update_repo_from_fs(mut repo, false)!
551 app.delete_repository_files_in_branch(repo_id, branch_name)!
552}
553
554// bg_recompute_lang_stats recomputes language statistics for a repo in a
555// background thread. It opens its own sqlite connection (matching the
556// clone_repo / bg_fetch_files_info pattern) because the shared App.db
557// handle is not safe for concurrent use across threads.
558fn bg_recompute_lang_stats(repo_id int, conf config.Config) {
559 mut app := &App{
560 db: connect_db(conf) or {
561 eprintln('bg_recompute_lang_stats: cannot open ${db_backend_name()} db: ${err}')
562 return
563 }
564 config: conf
565 }
566 app.load_settings()
567 defer {
568 app.db.close() or {}
569 }
570
571 repo := app.find_repo_by_id(repo_id) or {
572 eprintln('bg_recompute_lang_stats: repo ${repo_id} not found')
573 return
574 }
575 repo.analyze_lang(app) or {
576 eprintln('bg_recompute_lang_stats: analyze_lang failed for repo ${repo_id}: ${err}')
577 }
578}
579
580fn (r &Repo) analyze_lang(app &App) ! {
581 file_paths := r.get_all_file_paths()
582
583 mut all_size := 0
584 mut lang_stats := map[string]int{}
585 mut langs := map[string]highlight.Lang{}
586
587 for file_path in file_paths {
588 lang := highlight.extension_to_lang(file_path.split('.').last()) or { continue }
589 file_content := r.read_file(r.primary_branch, file_path)
590 lines := file_content.split_into_lines()
591 size := calc_lines_of_code(lines, lang)
592
593 if lang.name !in lang_stats {
594 lang_stats[lang.name] = 0
595 }
596 if lang.name !in langs {
597 langs[lang.name] = lang
598 }
599
600 lang_stats[lang.name] = lang_stats[lang.name] + size
601 all_size += size
602 }
603
604 mut d_lang_stats := []LangStat{}
605 mut tmp_a := []int{}
606
607 for lang, amount in lang_stats {
608 // skip 0 lines of code
609 if amount == 0 {
610 continue
611 }
612
613 mut tmp := f32(amount) / f32(all_size)
614 tmp *= 1000
615 pct := int(tmp)
616 if pct !in tmp_a {
617 tmp_a << pct
618 }
619 lang_data := langs[lang]
620 d_lang_stats << LangStat{
621 repo_id: r.id
622 name: lang_data.name
623 pct: pct
624 color: lang_data.color
625 lines_count: amount
626 }
627 }
628
629 tmp_a.sort()
630 tmp_a = tmp_a.reverse()
631
632 mut tmp_stats := []LangStat{}
633
634 for pct in tmp_a {
635 all_with_ptc := r.lang_stats.filter(it.pct == pct)
636 for lang in all_with_ptc {
637 tmp_stats << lang
638 }
639 }
640
641 app.remove_repo_lang_stats(r.id)!
642
643 for lang_stat in d_lang_stats {
644 app.add_lang_stat(lang_stat)!
645 }
646}
647
648fn calc_lines_of_code(lines []string, lang highlight.Lang) int {
649 mut size := 0
650 lcomment := lang.line_comments
651 mut mlcomment_start := ''
652 mut mlcomment_end := ''
653 if lang.mline_comments.len >= 2 {
654 mlcomment_start = lang.mline_comments[0]
655 mlcomment_end = lang.mline_comments[1]
656 }
657 mut in_comment := false
658 for line in lines {
659 tmp_line := line.trim_space()
660 if tmp_line.len > 0 { // Empty line ignored
661 if tmp_line.contains(mlcomment_start) {
662 in_comment = true
663 if tmp_line.starts_with(mlcomment_start) {
664 continue
665 }
666 }
667 if tmp_line.contains(mlcomment_end) {
668 if in_comment {
669 in_comment = false
670 }
671 if tmp_line.ends_with(mlcomment_end) {
672 continue
673 }
674 }
675 if in_comment {
676 continue
677 }
678 if tmp_line.contains(lcomment) {
679 if tmp_line.starts_with(lcomment) {
680 continue
681 }
682 }
683 size++
684 }
685 }
686 return size
687}
688
689fn (r &Repo) get_all_file_paths() []string {
690 ls_output := r.git('ls-tree -r ${r.primary_branch} --name-only')
691 mut file_paths := []string{}
692
693 for file_path in ls_output.split_into_lines() {
694 path_parts := file_path.split('/')
695 has_ignored_folders := path_parts.any(ignored_folder.contains(it))
696
697 if has_ignored_folders {
698 continue
699 }
700
701 file_paths << file_path
702 }
703
704 return file_paths
705}
706
707// TODO: return ?string
708fn (r &Repo) git(command string) string {
709 if command.contains('&') || command.contains(';') {
710 return ''
711 }
712 println('git(): "${command}"')
713
714 command_with_path := '-C ${r.git_dir} ${command}'
715
716 command_result := git.Git.exec_in_dir_command(r.git_dir, command)
717 command_exit_code := command_result.exit_code
718 if command_exit_code != 0 {
719 println('git error ${command_with_path} with ${command_exit_code} exit code out=${command_result.output}')
720
721 return ''
722 }
723
724 return command_result.output.trim_space()
725}
726
727fn (r &Repo) parse_ls(ls_line string, branch string) ?File {
728 ls_line_parts := ls_line.fields()
729 if ls_line_parts.len < 4 {
730 return none
731 }
732
733 item_type := ls_line_parts[1]
734 item_size := ls_line_parts[3]
735 item_path := ls_line_parts[4]
736
737 item_name := item_path.after('/')
738 if item_name == '' {
739 return none
740 }
741
742 mut parent_path := os.dir(item_path)
743 if parent_path == item_name {
744 parent_path = ''
745 }
746
747 if item_name.contains('"\\') {
748 // Unqoute octal UTF-8 strings
749 }
750
751 return File{
752 name: item_name
753 parent_path: parent_path
754 repo_id: r.id
755 branch: branch
756 is_dir: item_type == 'tree'
757 size: if item_type == 'blob' { item_size.int() } else { 0 }
758 is_size_calculated: item_type == 'blob'
759 }
760}
761
762fn (r &Repo) parse_top_file_line(line string, branch string) ?File {
763 tab_pos := line.index('\t') or { return none }
764 meta := line[..tab_pos]
765 item_path := line[tab_pos + 1..]
766 meta_parts := meta.fields()
767 if meta_parts.len < 4 || meta_parts[1] != 'blob' {
768 return none
769 }
770
771 lower_path := item_path.to_lower()
772 for segment in lower_path.split('/') {
773 if segment == 'thirdparty' || segment == '3rdparty' || segment == 'third_party'
774 || segment == 'third-party' {
775 return none
776 }
777 }
778
779 excluded_extensions := ['.png', '.jpg', '.jpeg', '.obj', '.json', '.pdf']
780 for ext in excluded_extensions {
781 if lower_path.ends_with(ext) {
782 return none
783 }
784 }
785
786 item_name := item_path.after('/')
787 if item_name == '' {
788 return none
789 }
790
791 parent_path_raw := os.dir(item_path)
792 parent_path := if parent_path_raw == '.' { '' } else { parent_path_raw }
793
794 return File{
795 name: item_name
796 parent_path: parent_path
797 repo_id: r.id
798 branch: branch
799 is_dir: false
800 size: meta_parts[3].int()
801 is_size_calculated: true
802 }
803}
804
805fn (r &Repo) lookup_file_via_git(branch string, path string) ?File {
806 git_result := git.Git.exec_in_dir(r.git_dir, ['ls-tree', '--full-name', '--long', branch, '--',
807 path])
808 if git_result.exit_code != 0 {
809 return none
810 }
811 for line in git_result.output.split_into_lines() {
812 tab_pos := line.index('\t') or { continue }
813 meta := line[..tab_pos]
814 item_path := line[tab_pos + 1..]
815 meta_parts := meta.fields()
816 if meta_parts.len < 4 || meta_parts[1] != 'blob' {
817 continue
818 }
819 item_name := item_path.after('/')
820 if item_name == '' {
821 continue
822 }
823 parent_path_raw := os.dir(item_path)
824 parent_path := if parent_path_raw == '.' { '' } else { parent_path_raw }
825 return File{
826 name: item_name
827 parent_path: parent_path
828 repo_id: r.id
829 branch: branch
830 is_dir: false
831 size: meta_parts[3].int()
832 is_size_calculated: true
833 }
834 }
835 return none
836}
837
838fn (r &Repo) top_files(branch string, limit int) []File {
839 git_result := git.Git.exec_in_dir(r.git_dir, ['ls-tree', '-r', '--full-name', '--long', branch])
840 if git_result.exit_code != 0 {
841 eprintln('git ls-tree top files error: ${git_result.output}')
842 return []File{}
843 }
844
845 mut files := []File{}
846 for line in git_result.output.split_into_lines() {
847 file := r.parse_top_file_line(line, branch) or { continue }
848 files << file
849 }
850
851 files.sort(b.size < a.size)
852 if files.len > limit {
853 return files[..limit]
854 }
855
856 return files
857}
858
859// Fetches all files via `git ls-tree` and saves them in db
860fn (mut app App) cache_repository_items(mut r Repo, branch string, path string) ![]File {
861 if r.status == .caching {
862 app.info('`${r.name}` is being cached already')
863 return []
864 }
865
866 mut repository_ls := ''
867 if path == '.' {
868 r.status = .caching
869
870 defer {
871 r.status = .done
872 }
873 } else {
874 directory_path := if path == '' { path } else { '${path}/' }
875 format := '%(objectmode) %(objecttype) %(objectname) %(objectsize) %(path)'
876 repository_ls =
877 r.git('ls-tree --full-name --format="${format}" ${branch} ${directory_path}')
878 }
879
880 // mode type name path
881 item_info_lines := repository_ls.split('\n')
882
883 mut dirs := []File{} // dirs first
884 mut files := []File{}
885
886 app.db.exec('BEGIN TRANSACTION')!
887
888 for item_info in item_info_lines {
889 is_item_info_empty := validation.is_string_empty(item_info)
890
891 if is_item_info_empty {
892 continue
893 }
894
895 file := r.parse_ls(item_info, branch) or {
896 app.warn('failed to parse ${item_info}')
897 continue
898 }
899
900 if file.is_dir {
901 dirs << file
902
903 app.add_file(file)!
904 } else {
905 files << file
906 }
907 }
908
909 dirs << files
910 for file in files {
911 app.add_file(file)!
912 }
913
914 app.db.exec('END TRANSACTION')!
915
916 return dirs
917}
918
919// fetches last message and last time for each file
920// this is slow, so it's run in the background thread
921fn (mut app App) slow_fetch_files_info(mut repo Repo, branch string, path string) ! {
922 files := app.find_repository_items(repo.id, branch, path)
923
924 for i in 0 .. files.len {
925 if files[i].last_msg != '' {
926 app.warn('skipping ${files[i].name}')
927 continue
928 }
929
930 app.fetch_file_info(repo, files[i])!
931 }
932}
933
934fn (mut app App) slow_fetch_folder_sizes(mut repo Repo, branch string, path string) ! {
935 files := app.find_repository_items(repo.id, branch, path)
936 dirs := files.filter(it.is_dir && !it.is_size_calculated)
937 if dirs.len == 0 {
938 return
939 }
940
941 dir_names := dirs.map(it.name)
942 sizes := repo.calculate_child_folder_sizes(branch, path, dir_names)
943
944 for dir in dirs {
945 size := sizes[dir.name] or { 0 }
946 app.update_file_size(dir.id, size, true)!
947 }
948}
949
950fn (r &Repo) calculate_child_folder_sizes(branch string, path string, dir_names []string) map[string]int {
951 mut sizes := map[string]int{}
952 for dir_name in dir_names {
953 sizes[dir_name] = 0
954 }
955 if dir_names.len == 0 {
956 return sizes
957 }
958
959 normalized_path := normalize_tree_path(path)
960 mut args := ['ls-tree', '-r', '--full-name', '--long', branch]
961 if normalized_path != '' {
962 args << '--'
963 args << normalized_path
964 }
965
966 result := git.Git.exec_in_dir(r.git_dir, args)
967 if result.exit_code != 0 {
968 eprintln('git ls-tree error while calculating folder sizes: ${result.output}')
969 return sizes
970 }
971
972 prefix := if normalized_path == '' { '' } else { '${normalized_path}/' }
973 for line in result.output.split_into_lines() {
974 tab_pos := line.index('\t') or { continue }
975 meta := line[..tab_pos]
976 item_path := line[tab_pos + 1..]
977 meta_parts := meta.fields()
978 if meta_parts.len < 4 || meta_parts[1] != 'blob' {
979 continue
980 }
981
982 mut relative_path := item_path
983 if prefix != '' {
984 if !item_path.starts_with(prefix) {
985 continue
986 }
987 relative_path = item_path[prefix.len..]
988 }
989
990 slash_pos := relative_path.index('/') or { continue }
991 child_dir := relative_path[..slash_pos]
992 if child_dir !in sizes {
993 continue
994 }
995
996 sizes[child_dir] = sizes[child_dir] + meta_parts[3].int()
997 }
998
999 return sizes
1000}
1001
1002fn normalize_tree_path(path string) string {
1003 return path.trim_string_left('/').trim_string_right('/')
1004}
1005
1006fn (r Repo) get_last_branch_commit_hash(branch_name string) string {
1007 git_result := git.Git.exec_in_dir(r.git_dir,
1008 ['log', '-n', '1', branch_name, '--pretty=format:%h'])
1009 git_output := git_result.output
1010
1011 if git_result.exit_code != 0 {
1012 eprintln('git log error: ${git_output}')
1013 }
1014
1015 return git_output
1016}
1017
1018fn (r Repo) git_advertise(service string) string {
1019 git_result := git.Git.exec([service, '--stateless-rpc', '--advertise-refs', r.git_dir])
1020 git_output := git_result.output
1021
1022 if git_result.exit_code != 0 {
1023 eprintln('git ${service} error: ${git_output}')
1024 }
1025
1026 return git_output
1027}
1028
1029fn (r Repo) archive_tag(tag string, path string, format ArchiveFormat) {
1030 // TODO: check tag name before running command
1031 r.git('archive ${tag} --format=${format} --output="${path}"')
1032}
1033
1034fn (r Repo) get_commit_patch(commit_hash string) ?string {
1035 patch := r.git('format-patch --stdout -1 ${commit_hash}')
1036
1037 if patch == '' {
1038 return none
1039 }
1040
1041 return patch
1042}
1043
1044fn (r Repo) git_smart(service string, input string) string {
1045 git_path := git.get_git_executable_path() or { 'git' }
1046 real_repository_path := os.real_path(r.git_dir)
1047
1048 mut process := os.new_process(git_path)
1049 process.set_args([service, '--stateless-rpc', real_repository_path])
1050
1051 process.set_redirect_stdio()
1052 process.run()
1053 process.stdin_write(input)
1054 process.stdin_write('\n')
1055
1056 output := process.stdout_slurp()
1057 errors := process.stderr_slurp()
1058
1059 process.wait()
1060 process.close()
1061
1062 if errors.len > 0 {
1063 eprintln('git ${service} error: ${errors}')
1064
1065 return ''
1066 }
1067
1068 return output
1069}
1070
1071fn (mut app App) generate_clone_url(repo Repo) string {
1072 hostname := app.config.hostname
1073 username := repo.user_name
1074 repo_name := repo.name
1075
1076 return 'https://${hostname}/${username}/${repo_name}.git'
1077}
1078
1079fn first_line(s string) string {
1080 pos := s.index('\n') or { return s }
1081 return s[..pos]
1082}
1083
1084fn (mut app App) fetch_file_info(r &Repo, file &File) ! {
1085 logs := r.git('log -n1 --format=%B___%at___%H___%an ${file.branch} -- ${file.full_path()}')
1086 vals := logs.split('___')
1087 if vals.len < 3 {
1088 return
1089 }
1090 last_msg := first_line(vals[0])
1091 last_time := vals[1].int()
1092 last_hash := vals[2]
1093
1094 file_id := file.id
1095 sql app.db {
1096 update File set last_msg = last_msg, last_time = last_time, last_hash = last_hash
1097 where id == file_id
1098 }!
1099}
1100
1101fn (mut app App) update_file_size(file_id int, size int, is_size_calculated bool) ! {
1102 sql app.db {
1103 update File set size = size, is_size_calculated = is_size_calculated where id == file_id
1104 }!
1105}
1106
1107fn (mut app App) update_repo_primary_branch(repo_id int, branch string) ! {
1108 sql app.db {
1109 update Repo set primary_branch = branch where id == repo_id
1110 }!
1111}
1112
1113fn (r &Repo) clone_progress_path() string {
1114 return r.git_dir + '.progress'
1115}
1116
1117fn (mut r Repo) clone() {
1118 eprintln('R CLONE')
1119 progress_path := r.clone_progress_path()
1120 clone_result := git.Git.clone_with_progress(r.clone_url, r.git_dir, progress_path)
1121 clone_exit_code := clone_result.exit_code
1122
1123 if clone_exit_code != 0 {
1124 r.status = .clone_failed
1125 println('git clone failed with exit code ${clone_exit_code}')
1126 return
1127 }
1128
1129 r.status = .done
1130 // progress file is no longer needed after a successful clone
1131 os.rm(progress_path) or {}
1132 eprintln('clone done')
1133}
1134
1135fn (r &Repo) read_file(branch string, path string) string {
1136 valid_path := path.trim_string_left('/')
1137
1138 println('read_file() path=${valid_path}')
1139 t := time.now()
1140 // s := r.git('--no-pager show ${branch}:${valid_path}')
1141
1142 s := git.Git.show_file_blob(r.git_dir, branch, valid_path) or { '' }
1143 println(time.since(t))
1144 println(':)')
1145 return s
1146}
1147
1148fn find_readme_file(items []File) ?File {
1149 files := items.filter(it.name.to_lower().starts_with('readme.') && it.name.split('.').len == 2
1150 && !it.is_dir)
1151
1152 if files.len == 0 {
1153 return none
1154 }
1155
1156 // firstly search markdown files
1157 readme_md_files := files.filter(it.name.to_lower().ends_with('.md'))
1158
1159 if readme_md_files.len > 0 {
1160 return readme_md_files.first()
1161 }
1162
1163 // and then txt files
1164 readme_txt_files := files.filter(it.name.to_lower().ends_with('.txt'))
1165
1166 if readme_txt_files.len > 0 {
1167 return readme_txt_files.first()
1168 }
1169
1170 return none
1171}
1172
1173fn find_license_file(items []File) ?File {
1174 // List of common license file names
1175 license_common_files := ['license', 'license.md', 'license.txt', 'licence', 'licence.md',
1176 'licence.txt']
1177
1178 files := items.filter(license_common_files.contains(it.name.to_lower()))
1179
1180 if files.len == 0 {
1181 return none
1182 }
1183 return files[0]
1184}
1185
1186fn (app &App) has_user_repo_read_access(ctx Context, user_id int, repo_id int) bool {
1187 if !ctx.logged_in {
1188 return false
1189 }
1190 repo := app.find_repo_by_id(repo_id) or { return false }
1191 if repo.is_public {
1192 return true
1193 }
1194 is_repo_owner := repo.user_id == user_id
1195 if is_repo_owner {
1196 return true
1197 }
1198 return false
1199}
1200
1201fn (app &App) has_user_repo_read_access_by_repo_name(ctx Context, user_id int, repo_owner_name string, repo_name string) bool {
1202 user := app.get_user_by_username(repo_owner_name) or { return false }
1203 repo := app.find_repo_by_name_and_user_id(repo_name, user.id) or { return false }
1204 return app.has_user_repo_read_access(ctx, user_id, repo.id)
1205}
1206
1207fn (app &App) check_repo_owner(username string, repo_name string) bool {
1208 user := app.get_user_by_username(username) or { return false }
1209 repo := app.find_repo_by_name_and_user_id(repo_name, user.id) or { return false }
1210 return repo.user_id == user.id
1211}
1212