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