Repository source: docs/plans/archive/forte_activity_commits_view_step4_implementation_summary.md
Forte Activity Commits View — Step 4: Implementation Summary
Stage 1 - commits table populated from git log during read-model rebuild
Changes:
ReadModelBuilder::createSchema(): newcommitstable (idautoincrement PK,shaunique,author_name,author_email,committed_at,subject,file_count) pluscommits_committed_at_idxfor the pagination/sort access pattern Stage 2 will use;dropSchema()drops it alongside the other tables on rebuild.New
indexCommits(PDO $pdo): void, wired intorebuild()'s existing pipeline (measure('index_commits', ...)) alongsideindex_activity/etc. Onegit log --format=... --shortstatinvocation (not one call per commit) produces every commit's metadata plus its file count in a single pass; fields are split on\x1f(unit separator - can't appear in a commit subject), and a commit with no--shortstatline (e.g. an empty/merge commit) defaults tofile_count: 0rather than being skipped.
Verification:
php -l- no syntax errors.Ran
php scripts/rebuild_read_model.phpagainst the local fixture repo and queried the resultingcommitstable directly: 211 rows, exactly matchinggit -C state/local_repository log --oneline | wc -l.Spot-checked the known large "Import repository archive" commit's row:
subjectandfile_count(1955) both match the ground truth established earlier in this session (git show --staton that sha).Confirmed schema via
PRAGMA table_info(commits)andsqlite_master- all 7 columns and thecommits_committed_at_idxindex present as designed.
Notes:
While verifying this stage, discovered the local fixture's content repository (
state/local_repository) had accumulated 3,712 loose git objects with no packfile (from this long session's own testing), making the existing, unrelated per-filesourceCommitShaForPath()lookups (used byindexPosts()/indexThreadLabels()/etc., already present before this feature) take ~111ms each - across ~2,300 record files, that alone stretched a full rebuild from ~5s to ~144s. Rangit gcon the content repo (routine, non-destructive maintenance - packs loose objects, doesn't discard anything reachable) to restore normal performance; full rebuild is back to ~14s. This wasn't caused byindexCommits()and isn't part of this feature's scope, but was necessary to get a clean read while verifying it.An initial read of the constructed
git logcommand's printed output looked like the\x1fdelimiter had been stripped byescapeshellarg()- turned out to be a false alarm: it's a non-printing control character, so it's simply invisible when echoed to a terminal, not actually absent from the string. The real rebuild's correctly-parsed 211 rows confirm the delimiter is intact and parsing works as designed.
Stage 2 - Sort-aware fetch/count for commits
Changes:
fetchCommits(string $sortColumn, string $sortDirection, ?array $afterCursor = null): array(Application.php) - mirrorsfetchActivity()'s keyset-cursor pagination shape over thecommitstable; simpler thanfetchActivity()since a commit has no "view" to filter by, so the onlyWHEREfragment is the cursor itself.countCommitsTotal(): int- mirrorscountActivityViewTotal()'s purpose for the left-pane folder count; every commit counts, no per-view filter.resolveCommitSort()/commitSortSql()/commitSortValueFromItem()- mirrorresolveActivitySort()/activitySortSql()/activitySortValueFromItem()'s shapes; onlydateis sortable for now per the Step 3 plan's own scope decision, kept as separate small resolvers (not folded into the activity versions) since the two views' valid column sets are unrelated.Reuses
ACTIVITY_ITEM_LIMITas the page size rather than introducing a new constant for the same 100-row page size.
Verification:
php -l- no syntax errors.Reflection-invoked
fetchCommits()directly, paging through the full 211-rowcommitstable with the real cursor: 100 + 100 + 11 items across 3 pages,has_morecorrectly true/true/false, zero duplicate ids, zero order violations, and the total unique ids collected (211) exactly matchescountCommitsTotal().
Notes:
Same reflection-based verification approach used for
fetchActivity()in the earlier sortable-columns feature, applied here for consistency.
Stage 3 - Wire "Commits" as a 6th filter
Changes:
normalizeActivityView()'s allowlist gainscommits.renderForteActivity(): fetches commits viafetchCommits()/countCommitsTotal()as a parallel row set ($commitItems), kept out of$itemsById/$itemsentirely rather than merged - commits don't share the activity-item shape. Adds acommitsentry to both$viewPaginationand$viewCounts;$selectedItemIdis forced to''when$selectedView === 'commits'(no$viewItemIds['commits']to look up against - commit selection is Stage 4's job, not pre-selected here).New
templates/partials/paned_activity_commit_row.php- mirrorspaned_activity_item_row.php's markup/CSS classes so it fits the existing list styling with no new CSS; maps short-sha/subject/committed_at onto the same Kind/Label/Date column positions. Usesdata-paned-activity-id="commit-<sha>"(not the commit's own numericid) since activity items and commits are both autoincrement ids from separate tables and could otherwise collide; a separatedata-paned-activity-commit-shaattribute carries the raw sha for Stage 4.paned_activity_item_list.phploops$commitItemsafter$items, rendering each via the new partial; visibility is$selectedView === 'commits'directly (commit items carry noview_*flags the way activity items do, since the rule is the same for every commit row). The existing Load More button loop already iterates$viewPaginationgenerically, so it required no changes to pick up the newcommitsentry.paned_activity_filter_list.phprequired no changes at all - it already loops$viewCountsgenerically, so the newcommitsentry just appears.forte_activity.phpthreads$commitItemsthrough to the list partial.handleForteActivityPage()(/api/forte_activity_page): cursor decoding (identical shape for both) now happens once up front, then branches onview === 'commits'tofetchCommits()+ the new row partial with an early return, leaving the existing activity-item path (rows + detail articles + shared manifest blocks) completely untouched below it. Commit pages always returndetail_html: ''- Stage 4 fetches a commit's manifest on demand, not eagerly per row.
Verification:
php -lon all changed PHP/template files - no syntax errors.Default page load: "Commits" filter appears with folder count 211 (matches
countCommitsTotal()/Stage 1's row count exactly).?view=commits: page 1 has exactly 100 commit rows; a sample row (the known large commit) renders correctly -ff8f35ee366b(short sha) /Import repository archive repository.tar.gz(subject) / properly time-formatted date.Paged through the full 211-commit set via
/api/forte_activity_page?view=commitsend to end: page 1 (100,has_more=true) -> page 2 (100,has_more=true) -> page 3 (11,has_more=false) - the known large commit correctly appears only on the last page (it's an old commit, so under default newest-first order it sorts near the end), confirming both the row count and the sort order are correct.Confirmed commit rows carry
hiddenwhen the default view (all) is selected, and are absent from visibility (not merged into) the other 5 views' own row sets.Regression:
/api/forte_activity_page?view=all(existing Load More) still returns 100 rows correctly; classic/activity/, RSS (still exactly 100 items), and/backup/all unaffected. Server log clean throughout.
Notes:
Selecting a commit row currently does nothing visible yet (no matching detail element exists) - this is the expected, temporary gap Stage 4 closes; Stage 3's own scope is the list/pagination wiring only, not detail rendering.
Per the Step 3 plan's own scope decision, clicking the Kind/Label sort headers while browsing Commits has no effect on commit order (
resolveCommitSort()only recognizesdate, falling back to it for anything else) - deliberate, not a bug.
Stage 4 - On-demand commit detail (full manifest), not pre-rendered eagerly
Changes:
New
GET /api/forte_commit_detail?sha=<40-hex>(handleForteCommitDetail(),Application.php) reusesactivityCommitManifest()(the same persistently-cached lookup the classic activity views already use) and renders the existingpartials/activity_commit_manifest.phppartial, returning just its HTML as JSON. A commit's manifest is deliberately not included in the Stage 3 row/page payload at all - some commits touch thousands of files, and only one is ever viewed at a time, so eagerly rendering every loaded row's manifest would recreate the exact page-bloat problem the earlier shared-manifest-block mechanism was built to work around. Malformed sha -> 400; well-formed but unknown sha -> 404.paned_activity_reader.js:selectItem()now branches on whether the clicked/keyboard-navigated/URL-restored item id is a commit ("commit-<sha>"prefix). Commit selection goes through newshowCommitDetail(sha), which builds a one-off<article class="paned-content-post" data-paned-activity-commit-detail="<sha>">(kept out of the pre-renderedcontentItemsarray entirely) with a head built immediately from the already-rendered row's own subject/date text (commitRowSubjectAndDate()) - no fetch needed for that part - and a body that shows "Loading…" until the manifest fetch resolves, then swaps in the returned HTML. A per-shacommitDetailCacheobject skips the fetch entirely for a commit already viewed this session. A late-arriving response for a commit the user has since navigated away from is discarded (checked by re-reading the article's currentdata-paned-activity-commit-detailattribute before writing), so rapid clicking across large commits can't let a slow response clobber a newer selection.resetContentPane()and the non-commit branch ofselectItem()both hide the commit-detail article when leaving it, so it doesn't linger visible under an unrelated activity item or empty selection.
Verification:
php -l/node --check- no syntax errors.GET /api/forte_commit_detail?sha=ff8f35ee366b7d0a7c20e88fe6d3ba01a06bdd4e(the known 1955-file commit):status: ok, returned HTML contains exactly 1955<li>file entries - matches Stage 1's ground truth exactly.GET /api/forte_commit_detail?sha=<a small, single-file commit>:status: ok, exactly 1<li>entry.Malformed sha (
sha=notasha) -> 400. Well-formed but nonexistent sha (all-zero 40-hex) -> 404. Missingshaparam -> 400.GET /forte/activity/?view=commits(full page): 200, 100 commit rows present, "Commits" filter label renders, status bar shows "100 items" - unaffected by the new endpoint, since Stage 3 deliberately never eager-renders manifests.Confirmed via
curlthat the served/assets/paned_activity_reader.jsincludes the new Stage 4 functions (showCommitDetail/commitDetailCache/the new endpoint URL), ruling out a stale-asset mismatch.
Notes:
No browser automation available in this environment for this stage (no
chromium-cli, and the earlier CDP-attached-Chrome fallback used elsewhere in this session has known post-click navigation-wait limitations and process-leakage issues under repeated use) - verification here is HTTP/endpoint-level (exact response shape, file counts, status codes) plus careful code review of the client-side selection/caching/race-guard logic, consistent with how Stages 1-2 were verified (reflection/SQL, not a browser) before Stage 3 introduced markup that a page-levelcurlcheck could cover.Selecting an activity item (not a commit) is unaffected:
contentItems/showCommitManifestFor()(the existing shared-manifest-block mechanism for activity items, built during the earlier bug-fix work) are untouched by this stage - Stage 6 is where that mechanism gets removed, once Stage 5 gives activity items their own narrow relevant-files list instead of relying on it.
Stage 5 - Trim action detail views to relevant files only
Changes:
New
Application::activityItemRelevantFiles(array $item): arraybuilds an item's own narrow file list: its record, that record's detached signature (if any), and the signer's public key (if any) - plus, foridentity_bootstrapitems specifically, the paired identity record its own signature was signed with (resolved the same wayactivityCommitSignatureMetadata()resolves a signing key: signer identity -> OpenPGP fingerprint ->CanonicalPathResolver::identity(), not "any identity record the commit happens to contain").Investigating real data surfaced a wrong assumption in an earlier draft of this method: a signing key is normally established once and reused across every later post it signs, so the key (and sometimes even the signature) commonly lives in a different, earlier commit than the record it signs - confirmed by inspecting several real commits (
8e41631c5c80ff4...: record + signature only, no key). Filtering strictly against the item's own commit manifest (source_commit_files) would have silently dropped the public key from the relevant set in this - the common - case, contradicting the explicit requirement ("including the public key") the user asked for. Fixed by resolving the signature and public key independent of commit co-location: prefer the real manifest entry when the file happens to be part of the item's own commit (accurate git status), otherwise fall back to a standalone entry (standaloneRelevantFile()/standaloneSignatureRelevantFile()) built fromactivityCommitSignatureMetadata()and the item's own already-computedsource_path_href/source_signature_href- correct regardless of which commit actually introduced the file. Only the item's own record (and identity_bootstrap's paired identity record) are still looked up against the item's own commit manifest first, sincesource_commit_shais precisely "the commit that touched this path" for those two.fetchActivity()computesrelevant_filesonce per item (alongside the existingsource_commit_files/source_signature_*fields it already builds), so it flows automatically into both the full-page render (renderForteActivity()) and the Load More/AJAX append path (handleForteActivityPage()) - both already built their item pools fromfetchActivity().activity_commit_manifest.phpgains an optionalheadingparameter (default"Commit files", unchanged for the Commits view/Stage 4's endpoint) sopaned_activity_detail_article.phpcan pass"Relevant files"instead -"Commit files (3)"on a 3-file subset of a commit would have read as wrong, not just imprecise.paned_activity_detail_article.php: replaced the old$hasCommitManifest/shared-block-attribute logic with$relevantFiles = $item['relevant_files'], renderingactivity_commit_manifest.phpinline (no JS block-moving involved) when non-empty, falling back to the pre-existingsource_metadata.phpdisplay when empty (mirrors the old fallback's trigger condition exactly, just switched from "commit manifest resolution failed" to "no source_path at all"). The article no longer carriesdata-paned-activity-commit-sha- Stage 3/4's shared-manifest-block JS (showCommitManifestFor()) still runs harmlessly on every selection (its sha lookup now always misses, so it just hides all shared blocks and moves nothing) until Stage 6 removes it outright.
Verification:
php -lon all changed PHP/template files - no syntax errors.Reflection/HTTP tests against real fixture data, one case per code path: identity_bootstrap with its key established in the same commit (item 226) - 4 relevant files, in order (post record, identity record, detached signature, public key), all correctly linked. A normal signed post whose key was established in an earlier, different commit (item 1335, commit
8e41631c5c80ff4...) - 3 relevant files (record, signature, standalone public-key entry withstatus: current), with the signature's embedded "Public key:" link matching the standalone entry's own path exactly. An unsignedidentity_bootstrapitem sharing the 1955-file import commit (item 1, the same commit Stage 1 used as ground truth) - exactly 1 relevant file (its own record), not 1955 - this is the regression case the whole feature exists to fix. Twothread_labelitems with no signature - 1 relevant file each. In every case, heading read "Relevant files (N)" and the oldsource_metadata.phpfallback did not render (relevant_fileswas non-empty for every item in this fixture dataset - no item here has an emptysource_path, so the fallback branch itself is unexercised by live data, verified by code review instead).Confirmed the resolved standalone public-key href for item 1335 actually resolves (
GET /source/current/records/public-keys/openpgp-D07BA23778422C4D5C7A92B8A6C1CCEB2021496B.asc-> 200).Regression:
GET /api/forte_activity_page?view=all(Load More) - 100 articles, 1046activity-commit-manifestsections total (~10 per item, i.e. no accidental duplication), 200 OK, no PHP warnings/fatals in the response body. Default/forte/activity/page load - clean, no errors.
Notes:
paned_activity_reader.jswas not touched in this stage - deliberately left as-is (see Changes above); Stage 6 is whereshowCommitManifestFor()/commitManifestBlocksand the PHP-side shared-block emission ($commitManifestsByShainpaned_activity_detail_pane.php/handleForteActivityPage()) get removed together, once this stage has proven the narrow relevant-files list is a complete replacement.
Stage 6 - Remove the shared-manifest-block machinery
Changes:
paned_activity_reader.js: removedshowCommitManifestFor()and its call site inselectItem(), thecommitManifestBlockstracking array and its three assignments (init()and both places the AJAX append path refreshed it), and the shared-block dedup branch insidemergeAppendedDetailArticles()(a node'sdata-paned-activity-commit-manifestattribute never appears in server output any more, so that branch was already unreachable - removed rather than left as dead code).data-paned-activity-commit-shaon commit rows (Stage 3/4's own, distinct attribute, used bycommitRowSubjectAndDate()) is unrelated and untouched.paned_activity_detail_pane.php: removed the$commitManifestsByShadedup-and-collect loop and the trailingforeachthat rendered each unique commit's full manifest as ahiddenblock after the items loop - Stage 5 already made every item self-sufficient (relevant_filesrendered inline), so nothing referenced these blocks any more.Application::handleForteActivityPage()(the Load More/AJAX append path): removed the equivalent$commitManifestsByShadedup loop that appended the same kind of hidden block onto$detailHtmlper page.
Verification:
php -l/node --checkon all changed files - no syntax errors.GET /forte/activity/(full page): 200, 0 occurrences ofdata-paned-activity-commit-manifest(down from Stage 3-5's non-zero count - the shared blocks are gone entirely, not just unused).GET /forte/activity/?selected=226(the 4-file identity_bootstrap case from Stage 5): still renders exactly 4<li>entries under "Relevant files (4)" - selection/rendering is unaffected by removing the now-dead machinery around it.GET /api/forte_activity_page?view=all(Load More): 200, 100 articles, 0data-paned-activity-commit-manifestoccurrences in the returneddetail_html.GET /forte/activity/?view=commits: 200, 100 commit rows still present - the Commits view (Stage 3) andGET /api/forte_commit_detail(Stage 4) are both untouched by this stage's removals, confirmed still working (large-commit manifest still returns correctly, 750KB/1955 files, matching Stage 4's own numbers exactly).No PHP warnings/fatals/deprecation notices in any of the above responses.
Notes:
This closes out the Step 3 plan's full 6-stage scope. Every action's detail view now shows only its own relevant files (Stage 5) and the Commits view lets a reader browse and inspect actual git commits on demand (Stages 1-4), with no leftover shared-block machinery from the interim design.
Post-completion fixes (after "Approved Step 4" was requested)
Redundant public-key row. User feedback on item 1335's rendering: the signature's own entry already names and links the signer's public key ("Signer: ... / Public key: <link>"), and
activityItemRelevantFiles()was also adding that same key as its own separate<li>- the two could never actually disagree (both resolved from the same signing-key lookup), so listing the file twice added nothing. Removed the standalone public-key entry fromactivityItemRelevantFiles(); a normal signed post now shows 2 relevant files (record, signature-with-embedded-key) instead of 3, andidentity_bootstrapshows 3 (record, identity record, signature-with-embedded-key) instead of 4. Updated Step 2's "Core Requirements"/"Success Criteria" language to match (the key is part of the signature's own entry, not a fourth/third row). Verified via the same item 1335/226 cases used in Stage 5:<li>counts dropped by exactly one each, the embedded "Public key:" link is unaffected, and the Commits view's own full-manifest endpoint (where a public key genuinely is its own committed file, unrelated to any single action) is untouched - this only changedactivityItemRelevantFiles(), notactivityCommitManifest().Regression-checked: Load More (
/api/forte_activity_page?view=all, 100 articles), Commits view (?view=commits, 200 rows), andGET /api/forte_commit_detail(large commit, 750KB/1955 files unchanged) all still correct.standaloneRelevantFile()is retained (still used for the identity_bootstrap identity-record fallback); only the public-key call site was removed.
Follow-up correction: the removal above was too broad. User clarified: when this action's own commit genuinely introduces the key (e.g. a fresh
identity_bootstrap, which adds the key file alongside the record/signature it authenticates), that key is one of the files this action actually added and should still get its own row - the "don't double-list" reasoning only applies when the key is merely referenced from an earlier, unrelated commit (the common case for an ordinary signed post), where it isn't a file this action added at all. Restored the public-key row, but now conditioned on co-location:$files[] = $byPath[$publicKeyPath]only when the key is actually part ofsource_commit_files(i.e.isset($byPath[$publicKeyPath])) - never as a standalone/fallback entry for a key from a different commit. Re-verified: item 226 (identity_bootstrap, key co-located) is back to 4 relevant files; item 1335 (ordinary signed post, key from an earlier commit) stays at 2; item 1 (unsigned, giant import commit) stays at 1. Updated Step 2's wording again to reflect the conditional rule.thread_label_add signing gap (user question, not a rendering bug). User asked why
thread_labelitems show no signature at all. Traced toLocalWriteService::applyThreadTag()(the "Apply thread tag ... to ..." write path): it requires anauthor_identity_idbut never calls the signature verifier or writes a.ascfile, unlike the signed-post/signed-identity-bootstrap write paths in the same class. This is a genuine gap in the write service, not an Activity-view display bug -thread_label_addactions are, as currently implemented, unsigned by design/omission. No code changed for this - reported back to the user as a factual answer; whether to add signing to that write path is a separate decision outside this feature's scope.