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 {
323 }323 }
324 }324 }
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
326 /// The commits reachable from `head` but not `base`, newest first — the commits361 /// The commits reachable from `head` but not `base`, newest first — the commits
327 /// a pull request would contribute. Capped at `limit`.362 /// a pull request would contribute. Capped at `limit`.
328 ///363 ///
@@ -1126,6 +1161,19 @@ mod tests {
1126 }1161 }
11271162
1128 #[test]1163 #[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]
1129 fn commit_returns_full_detail() {1177 fn commit_returns_full_detail() {
1130 let fx = fixture();1178 let fx = fixture();
1131 let repo = open(&fx);1179 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
74 let mut feed: Vec<FeedItem> = Vec::new();74 let mut feed: Vec<FeedItem> = Vec::new();
7575
76 for repo in &repos {76 for repo in &repos {
77 let rev_name = repo.default_branch.clone();77 // Walk every branch (deduplicated) so work on non-default branches counts.
78 let commits = git_read(state, &repo.id, move |r| {78 let commits = git_read(state, &repo.id, move |r| {
79 let rev = r.resolve_ref(&rev_name)?;79 r.commits_all_branches(SCAN_PER_REPO)
80 r.commits(&rev, None, git::Page::first(SCAN_PER_REPO))
81 })80 })
82 .await;81 .await;
83 let Ok(commits) = commits else { continue };82 let Ok(commits) = commits else { continue };
8483
85 for c in commits {84 for (c, branch) in commits {
86 if !c.author.email.eq_ignore_ascii_case(&email) {85 if !c.author.email.eq_ignore_ascii_case(&email) {
87 continue;86 continue;
88 }87 }
@@ -99,7 +98,7 @@ pub async fn compute(state: &AppState, user: &User, now_ms: i64) -> AppResult<Ac
99 feed.push(FeedItem {98 feed.push(FeedItem {
100 owner: user.username.clone(),99 owner: user.username.clone(),
101 repo: repo.path.clone(),100 repo: repo.path.clone(),
102 branch: repo.default_branch.clone(),101 branch,
103 short: c.oid.short().to_string(),102 short: c.oid.short().to_string(),
104 oid: c.oid.to_string(),103 oid: c.oid.to_string(),
105 summary: c.summary.clone(),104 summary: c.summary.clone(),