fabrica

hanna/fabrica

feat(web): dashboard activity counts all branches

a0a4f0f · hanna committed on 2026-07-25

Add git commits_all_branches (walk every local branch, deduplicated, with the
branch each commit was first found on) and use it for the dashboard heatmap and
feed, so commits made on non-default branches now count and appear with their
actual branch.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Signed by hannaSSH key fingerprint: SHA256:4g9cWhkAAw8gwqhJUcVbSnGEvdGwklW+1Aa/fMUu59k
2 files changed · +52 −5UnifiedSplit
crates/git/src/repo.rs +48 −0
@@ -323,6 +323,41 @@ impl Repo {
323323 }
324324 }
325325
326+ /// Recent commits across **all** local branches, deduplicated, each paired
327+ /// with the first branch it was found on, for the dashboard activity feed and
328+ /// heatmap. `per_branch` bounds the walk per branch. Order is per-branch
329+ /// (callers sort by time).
330+ ///
331+ /// # Errors
332+ ///
333+ /// Returns [`GitError::Libgit2`] on a revwalk failure.
334+ pub fn commits_all_branches(
335+ &self,
336+ per_branch: usize,
337+ ) -> Result<Vec<(CommitSummary, String)>, GitError> {
338+ let branches = self.branch_names()?;
339+ let mut seen: std::collections::HashSet<String> = std::collections::HashSet::new();
340+ let mut out: Vec<(CommitSummary, String)> = Vec::new();
341+ for branch in &branches {
342+ let Ok(rev) = self.resolve_ref(branch) else {
343+ continue;
344+ };
345+ let start = git2::Oid::from_str(rev.oid.as_str())?;
346+ let mut walk = self.inner.revwalk()?;
347+ walk.set_sorting(Sort::TIME)?;
348+ walk.push(start)?;
349+ for oid in walk.take(per_branch) {
350+ let oid = oid?;
351+ if !seen.insert(oid.to_string()) {
352+ continue;
353+ }
354+ let commit = self.inner.find_commit(oid)?;
355+ out.push((commit_summary(&commit), branch.clone()));
356+ }
357+ }
358+ Ok(out)
359+ }
360+
326361 /// The commits reachable from `head` but not `base`, newest first — the commits
327362 /// a pull request would contribute. Capped at `limit`.
328363 ///
@@ -1126,6 +1161,19 @@ mod tests {
11261161 }
11271162
11281163 #[test]
1164+ fn commits_all_branches_dedupes_and_covers_feature() {
1165+ let fx = fixture();
1166+ let repo = open(&fx);
1167+ let all = repo.commits_all_branches(50).unwrap();
1168+ // main has 2 commits, feature adds 1 on top; the shared two are counted
1169+ // once, so three distinct commits total.
1170+ assert_eq!(all.len(), 3, "deduplicated across branches");
1171+ // The feature-only commit is present, attributed to `feature`.
1172+ let feat = all.iter().find(|(c, _)| c.summary == "add extra module");
1173+ assert_eq!(feat.map(|(_, b)| b.as_str()), Some("feature"));
1174+ }
1175+
1176+ #[test]
11291177 fn commit_returns_full_detail() {
11301178 let fx = fixture();
11311179 let repo = open(&fx);
crates/web/src/activity.rs +4 −5
@@ -74,15 +74,14 @@ pub async fn compute(state: &AppState, user: &User, now_ms: i64) -> AppResult<Ac
7474 let mut feed: Vec<FeedItem> = Vec::new();
7575
7676 for repo in &repos {
77- let rev_name = repo.default_branch.clone();
77+ // Walk every branch (deduplicated) so work on non-default branches counts.
7878 let commits = git_read(state, &repo.id, move |r| {
79- let rev = r.resolve_ref(&rev_name)?;
80- r.commits(&rev, None, git::Page::first(SCAN_PER_REPO))
79+ r.commits_all_branches(SCAN_PER_REPO)
8180 })
8281 .await;
8382 let Ok(commits) = commits else { continue };
8483
85- for c in commits {
84+ for (c, branch) in commits {
8685 if !c.author.email.eq_ignore_ascii_case(&email) {
8786 continue;
8887 }
@@ -99,7 +98,7 @@ pub async fn compute(state: &AppState, user: &User, now_ms: i64) -> AppResult<Ac
9998 feed.push(FeedItem {
10099 owner: user.username.clone(),
101100 repo: repo.path.clone(),
102- branch: repo.default_branch.clone(),
101+ branch,
103102 short: c.oid.short().to_string(),
104103 oid: c.oid.to_string(),
105104 summary: c.summary.clone(),