Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
40 commits
Select commit Hold shift + click to select a range
7e6db8e
editor: implement fork-on-edit workflow for article editing
pedrogaudencio Dec 13, 2025
fa785a6
editor: prevent editing fork when user owns repo for same subject
pedrogaudencio Dec 13, 2025
49e1cf3
editor: remove unused form parameter from handleForkAndEdit
pedrogaudencio Dec 16, 2025
bd3ef94
editor: fix getUniqueRepositoryName bugs and optimize
pedrogaudencio Dec 16, 2025
ba5bb2a
fork: parallelize permission queries
pedrogaudencio Dec 16, 2025
8b5c32d
fork: add ErrUserOwnsSubjectRepo error type
pedrogaudencio Dec 16, 2025
a64a811
editor: consolidate fork-on-edit permission logic
pedrogaudencio Dec 15, 2025
420c618
templates: update article editing logic
pedrogaudencio Dec 15, 2025
1f5ef1d
migrations: add composite indexes for fork-on-edit optimization
pedrogaudencio Dec 16, 2025
ecac092
editor: improve fork-on-edit error messages in templates
pedrogaudencio Dec 16, 2025
ca4a41a
i18n: replace hardcoded strings with translation variables in article…
pedrogaudencio Dec 16, 2025
76e4fa3
Merge branch 'master' into change-request-fork-article
pedrogaudencio Dec 17, 2025
35c540a
templates: add translation keys and simplify logic
pedrogaudencio Dec 18, 2025
b760e1a
article: add confirmation modal to fork article button
pedrogaudencio Dec 19, 2025
77adece
article: add tooltip to Fork button
pedrogaudencio Dec 19, 2025
0ea2562
Merge branch 'master' into change-request-fork-article
pedrogaudencio Dec 19, 2025
d0438c4
tests: add e2e tests for fork article
pedrogaudencio Dec 20, 2025
7c688f1
tests: add fork-on-edit permission e2e tests
pedrogaudencio Dec 20, 2025
246e58f
tests: add edge case and accessibility tests for fork article modal
pedrogaudencio Dec 20, 2025
352d5a5
editor: fix fork-and-edit workflow for non-owners
pedrogaudencio Dec 21, 2025
28828f9
tests: fork-and-edit workflow
pedrogaudencio Dec 21, 2025
f1162b0
tests: add database migration tests for v326 and v327
pedrogaudencio Dec 21, 2025
4b1b055
api: add tests for fork with subject conflict and fix error handling …
pedrogaudencio Dec 21, 2025
9f8de0d
permissions: restrict fork_and_edit bypass to _edit and _new actions …
pedrogaudencio Dec 21, 2025
e699e2b
tests: add fork-on-edit permission tests
pedrogaudencio Dec 21, 2025
f0efd14
fork: replace type assertion with wrapped error support
pedrogaudencio Dec 21, 2025
90a12c6
templates: document fallback logic
pedrogaudencio Dec 21, 2025
fc3ee6c
repo: return errors from GetForkedRepo
pedrogaudencio Dec 21, 2025
2d929e2
linting: fix issues
pedrogaudencio Dec 22, 2025
3cc6c00
tests: use slices instead of forloop
pedrogaudencio Dec 22, 2025
a6a3609
tests: fix ESLint and TypeScript errors
pedrogaudencio Dec 22, 2025
1bcad13
tests: fix toastui-editor selector
pedrogaudencio Dec 22, 2025
482f29f
fork-on-edit: address code review findings
pedrogaudencio Dec 22, 2025
441306f
graph: filter fork contributor counts by creation time
pedrogaudencio Dec 27, 2025
a36224d
Merge branch 'master' into discard-contributors-on-fork
pedrogaudencio Dec 31, 2025
42fb934
repo: update time format in --since flag
pedrogaudencio Dec 31, 2025
58c52dd
fork-graph: add hasCommitsAfter helper
pedrogaudencio Dec 31, 2025
a99850a
graph: fix week-based edge case
pedrogaudencio Dec 31, 2025
cdc292c
graph: add TestHasCommitsAfter for mid-week edge case coverage
pedrogaudencio Dec 31, 2025
979f394
linting: fix issues
pedrogaudencio Dec 31, 2025
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
17 changes: 13 additions & 4 deletions modules/git/repo_stats.go
Original file line number Diff line number Diff line change
Expand Up @@ -154,15 +154,24 @@ func (repo *Repository) GetCodeActivityStats(fromTime time.Time, branch string)
return stats, nil
}

// GetContributorCount returns the number of unique contributors for the given branch
func (repo *Repository) GetContributorCount(branch string) (int64, error) {
// GetContributorCount returns the number of unique contributors for the given branch.
// If since is non-zero, only counts contributors who made commits after that time.
// This is useful for forks where we only want to count post-fork contributions.
func (repo *Repository) GetContributorCount(branch string, since time.Time) (int64, error) {
if len(branch) == 0 {
branch = "HEAD"
}

// Use git shortlog to get unique contributors efficiently
stdout, _, err := gitcmd.NewCommand("shortlog", "-sn", "--all").
AddDynamicArguments(branch).
cmd := gitcmd.NewCommand("shortlog", "-sn", "--all")

// If since is provided, only count commits after that time
// This is used for forks to exclude inherited history from the parent repository
if !since.IsZero() {
cmd.AddOptionFormat("--since=%s", since.Format(time.RFC3339))
}

stdout, _, err := cmd.AddDynamicArguments(branch).
RunStdString(repo.Ctx, &gitcmd.RunOpts{Dir: repo.Path})
if err != nil {
return 0, err
Expand Down
25 changes: 25 additions & 0 deletions modules/git/repo_stats_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -34,3 +34,28 @@ func TestRepository_GetCodeActivityStats(t *testing.T) {
assert.EqualValues(t, 3, code.Authors[1].Commits)
assert.EqualValues(t, 5, code.Authors[0].Commits)
}

func TestRepository_GetContributorCount(t *testing.T) {
bareRepo1Path := filepath.Join(testReposDir, "repo1_bare")
bareRepo1, err := OpenRepository(t.Context(), bareRepo1Path)
assert.NoError(t, err)
defer bareRepo1.Close()

// Test without since filter - should count all contributors
count, err := bareRepo1.GetContributorCount("master", time.Time{})
assert.NoError(t, err)
assert.Positive(t, count, "Expected at least one contributor")

// Test with a future since date - should return 0 contributors
futureTime := time.Now().AddDate(1, 0, 0) // 1 year in the future
count, err = bareRepo1.GetContributorCount("master", futureTime)
assert.NoError(t, err)
assert.EqualValues(t, 0, count, "Expected 0 contributors for future since date")

// Test with a past since date that includes all commits
pastTime, err := time.Parse(time.RFC3339, "2016-01-01T00:00:00+00:00")
assert.NoError(t, err)
countWithPastSince, err := bareRepo1.GetContributorCount("master", pastTime)
assert.NoError(t, err)
assert.Positive(t, countWithPastSince, "Expected contributors with past since date")
}
33 changes: 26 additions & 7 deletions routers/web/explore/repo.go
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ import (
"net/http"
"path"
"strings"
"time"

"code.gitea.io/gitea/models/db"
"code.gitea.io/gitea/models/renderhelper"
Expand Down Expand Up @@ -505,7 +506,8 @@ func RenderRepositoryHistory(ctx *context.Context) {
if branch == "" {
branch = setting.Repository.DefaultBranch
}
if count, err := gitRepo.GetContributorCount(branch); err == nil {
// Root repo is not a fork, so count all contributors (no since filter)
if count, err := gitRepo.GetContributorCount(branch, time.Time{}); err == nil {
rootEntry.ContributorCount = count
} else {
log.Warn("GetContributorCount for %s: %v", rootRepo.FullName(), err)
Expand Down Expand Up @@ -537,7 +539,9 @@ func RenderRepositoryHistory(ctx *context.Context) {
if err != nil {
log.Warn("OpenRepository for fork %s: %v", fork.FullName(), err)
} else {
if count, err := forkGitRepo.GetContributorCount(branch); err == nil {
// For forks, only count contributors who made commits after the fork was created
// to exclude inherited history from the parent repository
if count, err := forkGitRepo.GetContributorCount(branch, fork.CreatedUnix.AsTime()); err == nil {
entry.ContributorCount = count
} else {
log.Warn("GetContributorCount for fork %s: %v", fork.FullName(), err)
Expand Down Expand Up @@ -618,8 +622,14 @@ func prepareArticleView(ctx *context.Context, gitRepo *git.Repository, entries [
}

// Get contributor count for the readme file (use default branch for contributor count)
// For forks, only count contributors who made commits after the fork was created
// to exclude inherited history from the parent repository
defaultBranch := ctx.Repo.Repository.DefaultBranch
contributorCount, err := getFileContributorCount(gitRepo, defaultBranch, readmeTreePath)
var contributorSince timeutil.TimeStamp
if ctx.Repo.Repository.IsFork {
contributorSince = ctx.Repo.Repository.CreatedUnix
}
contributorCount, err := getFileContributorCount(gitRepo, defaultBranch, readmeTreePath, contributorSince)
if err != nil {
log.Warn("Failed to get contributor count: %v", err)
contributorCount = 0
Expand Down Expand Up @@ -805,11 +815,20 @@ func processGitCommits(ctx *context.Context, commits []*git.Commit) ([]*user_mod
return userCommits, nil
}

// getFileContributorCount gets the number of unique contributors for a specific file
func getFileContributorCount(gitRepo *git.Repository, branch, filePath string) (int64, error) {
// getFileContributorCount gets the number of unique contributors for a specific file.
// If since is non-zero, only counts contributors who made commits after that time.
// This is useful for forks where we only want to count post-fork contributions.
func getFileContributorCount(gitRepo *git.Repository, branch, filePath string, since timeutil.TimeStamp) (int64, error) {
// Use git shortlog to get unique contributors for the file
stdout, _, err := gitcmd.NewCommand("shortlog", "-sn").
AddDynamicArguments(branch, "--", filePath).
cmd := gitcmd.NewCommand("shortlog", "-sn")

// If since is provided, only count commits after that time
// This is used for forks to exclude inherited history from the parent repository
if since > 0 {
cmd.AddOptionFormat("--since=%s", since.AsTime().Format(time.RFC3339))
}

stdout, _, err := cmd.AddDynamicArguments(branch, "--", filePath).
RunStdString(gitRepo.Ctx, &gitcmd.RunOpts{Dir: gitRepo.Path})
if err != nil {
return 0, err
Expand Down
9 changes: 8 additions & 1 deletion services/context/repo.go
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ import (
"net/url"
"path"
"strings"
"time"

asymkey_model "code.gitea.io/gitea/models/asymkey"
"code.gitea.io/gitea/models/db"
Expand Down Expand Up @@ -1162,9 +1163,15 @@ func RepoAssignmentBySubject(ctx *Context) {
}

// Set up contributor count data
// For forks, only count contributors who made commits after the fork was created
// to avoid including inherited history from the parent repository
ctx.Data["ContributorCount"] = int64(0)
if !repo.IsEmpty && ctx.Repo.GitRepo != nil {
contributorCount, err := ctx.Repo.GitRepo.GetContributorCount(repo.DefaultBranch)
var since time.Time
if repo.IsFork {
since = repo.CreatedUnix.AsTime()
}
contributorCount, err := ctx.Repo.GitRepo.GetContributorCount(repo.DefaultBranch, since)
if err != nil {
log.Warn("Failed to get contributor count for repository %s: %v", repo.FullName(), err)
} else {
Expand Down
68 changes: 59 additions & 9 deletions services/repository/fork_graph.go
Original file line number Diff line number Diff line change
Expand Up @@ -291,7 +291,13 @@ func buildNode(ctx context.Context, repo *repo_model.Repository, level int, para

// Add contributor stats if requested
if params.IncludeContributors {
stats, err := getContributorStats(repo, params.ContributorDays)
// For forks, only count contributors who made commits after the fork was created
// to exclude inherited history from the parent repository
Comment on lines +294 to +295
Copy link

Copilot AI Dec 31, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The comment states "only count contributors who made commits after the fork was created", but due to week-based granularity in the contributor data, this may include contributors who have commits in weeks that overlap with the fork creation time, even if some commits were before the fork. Consider clarifying: "only count contributors who have commits in weeks that overlap with or occur after the fork was created".

Suggested change
// For forks, only count contributors who made commits after the fork was created
// to exclude inherited history from the parent repository
// For forks, only count contributors who have commits in weeks that overlap with
// or occur after the fork's creation time, to minimize inherited history from the parent

Copilot uses AI. Check for mistakes.
var since time.Time
if repo.IsFork {
since = repo.CreatedUnix.AsTime()
}
stats, err := getContributorStats(repo, params.ContributorDays, since)
if err != nil {
log.Warn("Failed to get contributor stats for repo %d: %v", repo.ID, err)
} else {
Expand All @@ -312,7 +318,13 @@ func createLeafNode(repo *repo_model.Repository, level int, params ForkGraphPara
}

if params.IncludeContributors {
stats, err := getContributorStats(repo, params.ContributorDays)
// For forks, only count contributors who made commits after the fork was created
// to exclude inherited history from the parent repository
Comment on lines +321 to +322
Copy link

Copilot AI Dec 31, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The comment states "only count contributors who made commits after the fork was created", but due to week-based granularity in the contributor data, this may include contributors who have commits in weeks that overlap with the fork creation time, even if some commits were before the fork. Consider clarifying: "only count contributors who have commits in weeks that overlap with or occur after the fork was created".

Suggested change
// For forks, only count contributors who made commits after the fork was created
// to exclude inherited history from the parent repository
// For forks, only count contributors who have commits in weeks that overlap with
// or occur after the fork's creation time, to approximate excluding inherited
// history from the parent repository

Copilot uses AI. Check for mistakes.
var since time.Time
if repo.IsFork {
since = repo.CreatedUnix.AsTime()
}
stats, err := getContributorStats(repo, params.ContributorDays, since)
if err != nil {
log.Warn("Failed to get contributor stats for repo %d: %v", repo.ID, err)
} else {
Expand Down Expand Up @@ -395,8 +407,30 @@ func sortRepositories(repos []*repo_model.Repository, sortBy string) {
})
}

// getContributorStats gets contributor statistics for a repository
func getContributorStats(repo *repo_model.Repository, days int) (*ContributorStats, error) {
// hasCommitsAfter checks if a contributor has any commits after the given time.
// Returns true if since is zero (no filtering) or if the contributor has at least one commit after since.
// Note: Since week.Week is the Unix timestamp of the start of the week (Sunday), we check if the
// week *ends* after since to include commits made mid-week after a fork created earlier that week.
Comment on lines +410 to +413
Copy link

Copilot AI Dec 31, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The documentation states this function checks if a contributor has commits "after" the given time, but due to week-based granularity, this is not entirely accurate. The function will include contributors who have commits in any week that overlaps with the post-fork period, even if all their commits in that week were made before the fork creation time.

Consider updating the documentation to clarify this limitation: "Returns true if the contributor has any week with commits that overlaps with or occurs after the since time. Note: Due to week-based granularity, this may include commits made before the since time if they fall within a week that extends past since."

Suggested change
// hasCommitsAfter checks if a contributor has any commits after the given time.
// Returns true if since is zero (no filtering) or if the contributor has at least one commit after since.
// Note: Since week.Week is the Unix timestamp of the start of the week (Sunday), we check if the
// week *ends* after since to include commits made mid-week after a fork created earlier that week.
// hasCommitsAfter checks if a contributor has any week of commits that overlaps with or occurs
// after the given time.
// Returns true if since is zero (no filtering) or if the contributor has at least one week with
// commits whose week-end is after since. Note: Since week.Week is the Unix timestamp of the start
// of the week (Sunday), this week-based granularity may include commits made before since if they
// fall within a week that extends past since.

Copilot uses AI. Check for mistakes.
func hasCommitsAfter(contributor *ContributorData, since time.Time) bool {
if since.IsZero() {
return true
}
for _, week := range contributor.Weeks {
weekTime := time.UnixMilli(week.Week)
// Check if the week ends after since (week end = week start + 7 days)
// This ensures we include weeks that overlap with the post-fork period
weekEndTime := weekTime.AddDate(0, 0, 7)
if weekEndTime.After(since) && week.Commits > 0 {
return true
}
}
return false
}

// getContributorStats gets contributor statistics for a repository.
// If since is non-zero, only counts contributors who made commits after that time.
// This is useful for forks where we only want to count post-fork contributions.
Comment on lines +431 to +432
Copy link

Copilot AI Dec 31, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The documentation states this function "only counts contributors who made commits after that time", but due to week-based granularity of the contributor data, this is not entirely accurate. It will count contributors who have commits in any week that overlaps with the post-fork period, even if some or all commits were made before the fork.

Consider clarifying: "If since is non-zero, only counts contributors who have at least one week with commits that overlaps with or occurs after that time. Note: Due to week-based granularity, this may include some commits made before the since time."

Suggested change
// If since is non-zero, only counts contributors who made commits after that time.
// This is useful for forks where we only want to count post-fork contributions.
// If since is non-zero, only counts contributors who have at least one week with commits
// that overlaps with or occurs after that time. This is useful for forks where we only
// want to count post-fork contributions. Note: Due to week-based granularity, this may
// include some commits made before the since time.

Copilot uses AI. Check for mistakes.
func getContributorStats(repo *repo_model.Repository, days int, since time.Time) (*ContributorStats, error) {
// Use existing contributor stats service
c := cache.GetCache()
if c == nil {
Expand All @@ -416,12 +450,23 @@ func getContributorStats(repo *repo_model.Repository, days int) (*ContributorSta
}

// Count total contributors (exclude "total" summary entry)
totalCount := len(stats)
if _, hasTotal := stats["total"]; hasTotal {
totalCount-- // Don't count the "total" summary as a contributor
// For forks, only count contributors who have commits after the fork creation time
totalCount := 0
for email, contributor := range stats {
// Skip the "total" summary entry
if email == "total" {
continue
}

// For forks, skip contributors with no post-fork commits
if !hasCommitsAfter(contributor, since) {
continue
}

totalCount++
}

// Count recent contributors
// Count recent contributors (within the specified days window)
cutoffTime := time.Now().AddDate(0, 0, -days)
recentCount := 0

Expand All @@ -431,7 +476,12 @@ func getContributorStats(repo *repo_model.Repository, days int) (*ContributorSta
continue
}

// Check if contributor has commits in the time window
// For forks, skip contributors with no post-fork commits
if !hasCommitsAfter(contributor, since) {
continue
}

// Check if contributor has commits in the recent time window
for _, week := range contributor.Weeks {
weekTime := time.UnixMilli(week.Week)
if weekTime.After(cutoffTime) && week.Commits > 0 {
Expand Down
80 changes: 78 additions & 2 deletions services/repository/fork_graph_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -227,14 +227,90 @@ func TestGetContributorStats(t *testing.T) {

repo := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{ID: 1})

// Test getting contributor stats
stats, err := getContributorStats(repo, 90)
// Test getting contributor stats without since filter (non-fork)
stats, err := getContributorStats(repo, 90, time.Time{})

// Should not error even if stats are not available
assert.NoError(t, err)
assert.NotNil(t, stats)
assert.GreaterOrEqual(t, stats.TotalCount, 0)
assert.GreaterOrEqual(t, stats.RecentCount, 0)

// Test getting contributor stats with since filter (simulating fork)
// Using a future time should result in 0 contributors
futureTime := time.Now().AddDate(1, 0, 0)
statsWithFutureSince, err := getContributorStats(repo, 90, futureTime)
assert.NoError(t, err)
assert.NotNil(t, statsWithFutureSince)
assert.Equal(t, 0, statsWithFutureSince.TotalCount, "Expected 0 contributors for future since date")
assert.Equal(t, 0, statsWithFutureSince.RecentCount, "Expected 0 recent contributors for future since date")
}

func TestHasCommitsAfter(t *testing.T) {
// Test the hasCommitsAfter helper function directly to verify mid-week edge case handling

// Create a mock contributor with commits in a specific week
// Week starts on Sunday 2024-01-07 (Unix timestamp in milliseconds)
weekStart := time.Date(2024, 1, 7, 0, 0, 0, 0, time.UTC) // Sunday
weekStartMs := weekStart.UnixMilli()

contributor := &ContributorData{
Name: "Test User",
TotalCommits: 5,
Weeks: map[int64]*WeekData{
weekStartMs: {
Week: weekStartMs,
Commits: 5,
},
},
}

// Test 1: Zero since time should always return true
assert.True(t, hasCommitsAfter(contributor, time.Time{}),
"Zero since time should return true")

// Test 2: Since time before the week should return true
beforeWeek := time.Date(2024, 1, 1, 0, 0, 0, 0, time.UTC) // Before the week
assert.True(t, hasCommitsAfter(contributor, beforeWeek),
"Since time before the week should return true")

// Test 3: Since time after the week ends should return false
afterWeek := time.Date(2024, 1, 15, 0, 0, 0, 0, time.UTC) // After the week ends
assert.False(t, hasCommitsAfter(contributor, afterWeek),
"Since time after the week ends should return false")

// Test 4: MID-WEEK EDGE CASE - Since time mid-week (Wednesday) should return true
// This is the key edge case: fork created on Wednesday, commits exist in that week
// The week started Sunday (before fork) but ends Sunday (after fork)
// So commits made Thursday-Saturday should be counted
Comment on lines +283 to +285
Copy link

Copilot AI Dec 31, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The comment "So commits made Thursday-Saturday should be counted" is misleading. Due to the week-based granularity of the contributor data, the function cannot distinguish between commits made on different days within the same week. It will count ALL commits in the week (Sunday-Saturday, including those before the Wednesday fork) if the week overlaps with the post-fork period, not just Thursday-Saturday commits.

Suggested change
// This is the key edge case: fork created on Wednesday, commits exist in that week
// The week started Sunday (before fork) but ends Sunday (after fork)
// So commits made Thursday-Saturday should be counted
// This is the key edge case: fork created on Wednesday, commits exist in that week.
// Contributor data is week-based (Sunday-Saturday). If the week overlaps the
// post-fork period at all, all commits in that week are treated as post-fork
// for hasCommitsAfter, even though we cannot distinguish which specific days
// within the week the commits occurred.

Copilot uses AI. Check for mistakes.
midWeek := time.Date(2024, 1, 10, 12, 0, 0, 0, time.UTC) // Wednesday noon
assert.True(t, hasCommitsAfter(contributor, midWeek),
"Mid-week since time should return true because week ends after since")

// Test 5: Since time exactly at week end should return false
// Week ends at the start of the next Sunday (2024-01-14 00:00:00)
weekEnd := time.Date(2024, 1, 14, 0, 0, 0, 0, time.UTC)
assert.False(t, hasCommitsAfter(contributor, weekEnd),
"Since time at week end should return false")

// Test 6: Since time just before week end should return true
justBeforeWeekEnd := time.Date(2024, 1, 13, 23, 59, 59, 0, time.UTC) // Saturday 23:59:59
assert.True(t, hasCommitsAfter(contributor, justBeforeWeekEnd),
"Since time just before week end should return true")

// Test 7: Contributor with no commits should return false
emptyContributor := &ContributorData{
Name: "Empty User",
TotalCommits: 0,
Weeks: map[int64]*WeekData{
weekStartMs: {
Week: weekStartMs,
Commits: 0, // No commits in this week
},
},
}
assert.False(t, hasCommitsAfter(emptyContributor, beforeWeek),
"Contributor with no commits should return false even with valid since time")
}

func TestProcessingTimeout(t *testing.T) {
Expand Down