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