Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
21 commits
Select commit Hold shift + click to select a range
788b15a
fix: scope upsertDoc delete to protect branch overlays and main
efenocchi Jul 10, 2026
32d2a55
feat: thread scope selector through doc write resolution
efenocchi Jul 10, 2026
d2d019f
feat: derive doc branch scope from git checkout
efenocchi Jul 10, 2026
564252f
feat: add scope-precedence resolver for branch-aware reads
efenocchi Jul 10, 2026
1093499
feat: resolve docs by reader branch scope on the read path
efenocchi Jul 10, 2026
49f03b7
feat: stamp doc writes with the current git branch scope
efenocchi Jul 10, 2026
d51e457
feat: create branch overlays copy-on-write on wiki refresh
efenocchi Jul 10, 2026
32a10b0
feat: seed a branch refresh window from the trunk merge-base
efenocchi Jul 10, 2026
d93c2cd
feat: add per-page source fingerprint from git blob shas
efenocchi Jul 10, 2026
1f54f42
feat: persist per-page source fingerprint on wiki writes
efenocchi Jul 10, 2026
dd0f54a
feat: serve docs with a staleness banner from the fingerprint
efenocchi Jul 10, 2026
add287b
feat: hold branch overlays until their source is pushed to origin
efenocchi Jul 10, 2026
408fb8e
feat: promote merged branch overlays to main on refresh
efenocchi Jul 10, 2026
2ffa545
fix: harden branch-scope read isolation and scope-aware listings
efenocchi Jul 10, 2026
55af417
fix: hold uncommitted/detached docs and guard promotion membership
efenocchi Jul 10, 2026
d03568a
feat: materialize private branch docs in a local store until pushed
efenocchi Jul 10, 2026
6188d54
fix: keep private pages publishable and isolate the private store
efenocchi Jul 10, 2026
cb15f56
fix: never regenerate a private page to the shared cloud
efenocchi Jul 10, 2026
97a0bc4
feat: offer auto-refresh after manual docs generation
efenocchi Jul 10, 2026
30430f6
fix: never promote overlays into main on detached HEAD
efenocchi Jul 10, 2026
9947bf0
fix: serve private docs for empty-string project key
efenocchi Jul 10, 2026
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
93 changes: 89 additions & 4 deletions src/commands/docs.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,7 @@
* SQL escaping and version-bump logic lives in the docs module.
*/

import { readFileSync } from "node:fs";
import { existsSync, readFileSync } from "node:fs";
import { loadConfig } from "../config.js";
import { DeeplakeApi } from "../deeplake-api.js";
import { getVersion } from "../cli/version.js";
Expand Down Expand Up @@ -55,13 +55,15 @@ import { setAuto, findEntry, listEntries } from "../docs/auto-registry.js";
import { defaultIo, runDocsOnboarding } from "../docs/onboarding.js";
import { isAutoEnabled } from "../docs/auto-registry.js";
import { readRefreshMeta } from "../docs/meta.js";
import { tryGitTopLevel } from "../graph/git-hook-install.js";
import { tryGitTopLevel, postCommitHookPath, containsOurMarkers } from "../graph/git-hook-install.js";
import { runWikiRefreshCycle, runLocalWikiRefresh } from "../docs/wiki-refresh.js";
import { loadSnapshotByCommit } from "../graph/diff.js";
import { repoDir } from "../graph/snapshot.js";
import { execFileSync } from "node:child_process";
import { hostname, userInfo } from "node:os";
import type { GitRunner } from "../docs/candidates.js";
import { defaultGit } from "../docs/candidates.js";
import { currentScope } from "../docs/branch-scope.js";
import { deriveProjectKey } from "../utils/repo-identity.js";
import { makeDocEmbedder } from "../docs/embed.js";
import { backfillDocEmbeddings } from "../docs/backfill.js";
Expand Down Expand Up @@ -119,6 +121,74 @@ function gitHeadOf(cwd: string): string | null {
}
}

/**
* True iff a hivemind-managed post-commit hook is installed for `cwd`. The hook
* is one of the two refresh triggers; its presence is what upgrades the wiki
* from "refreshes on session start" to "refreshes on every commit".
*/
function hookInstalledAt(cwd: string): boolean {
const p = postCommitHookPath(cwd);
if (!p) return false;
try {
return existsSync(p) && containsOurMarkers(readFileSync(p, "utf8"));
} catch {
return false; // unreadable hook → treat as absent, never throw on a hint path
}
}

/**
* The freshness hint printed after a manual generation command when the user
* declines (or can't be asked about) auto refresh. A corpus is only kept up to
* date when the auto-registry flag is ON — it gates BOTH refresh triggers (the
* post-commit hook AND the SessionStart catch-up tick). The hook adds instant
* per-commit refresh on top. With the flag OFF nothing refreshes the corpus at
* all, so a freshly-generated corpus silently goes stale — this hint is the
* source-level close of that gap. `subject` names what stays fresh, phrased to
* open a sentence ("This wiki" / "These docs"), so the message matches the
* command that printed it. Returns null when both are wired (no nag).
*/
export function wikiFreshnessHint(state: { autoEnabled: boolean; hookInstalled: boolean; subject: string }): string | null {
if (state.autoEnabled && state.hookInstalled) return null; // fully wired
if (state.autoEnabled) {
// Refreshes on session start already; only the per-commit hook is missing.
return "Auto refresh is ON (updates on session start). For instant per-commit refresh: hivemind graph init";
}
// Flag OFF → the corpus will NOT refresh until enabled.
const base = `${state.subject} will NOT stay fresh — nothing refreshes it yet. Enable per-session refresh: hivemind docs auto on`;
return state.hookInstalled ? base : `${base} (and per-commit refresh: hivemind graph init)`;
}

/**
* After a manual generation command (`docs wiki` / `docs generate`) writes a
* corpus, wire freshness. A human is ASKED to turn on auto refresh; on yes we
* flip the same per-(org, project) registry flag `docs auto on` sets, so both
* the post-commit hook and the SessionStart tick will keep the corpus fresh.
* Automation (no TTY) is never blocked on stdin — it only sees the passive
* `wikiFreshnessHint`. `subject` names what stays fresh in the prompt
* ("this wiki" / "these docs"). No-op when auto is already on.
*/
async function offerAutoRefresh(
cfg: ReturnType<typeof requireConfig>,
project: string,
cwd: string,
subject: string,
): Promise<void> {
if (isAutoEnabled(cfg.orgId, project)) return;
const io = defaultIo();
if (io.interactive) {
const a = await io.ask(`\nKeep ${subject} fresh automatically on every commit? [y/N] `);
if (/^y(es)?$/i.test(a.trim())) {
setAuto({ orgId: cfg.orgId, orgName: cfg.orgName, project, path: cwd, auto: true });
console.log("Auto refresh ON. For instant per-commit refresh also run: hivemind graph init");
return;
}
}
// Capitalize the subject to open the hint sentence ("this wiki" → "This wiki").
const sentenceSubject = subject.charAt(0).toUpperCase() + subject.slice(1);
const hint = wikiFreshnessHint({ autoEnabled: false, hookInstalled: hookInstalledAt(cwd), subject: sentenceSubject });
if (hint) console.log(`\n${hint}`);
}

function requireConfig(): NonNullable<ReturnType<typeof loadConfig>> {
const cfg = loadConfig();
if (!cfg) {
Expand Down Expand Up @@ -403,7 +473,8 @@ export async function runDocsCommand(args: string[]): Promise<void> {
}
let row: DocRow | null = null;
try {
row = await getDocLatest(query, tableName, docId);
const showCwd = flagValue(args, "--cwd") ?? process.cwd();
row = await getDocLatest(query, tableName, docId, { readerScope: currentScope(defaultGit(showCwd)) });
} catch (err) {
if (!isMissingTableError((err as Error).message)) throw err;
}
Expand Down Expand Up @@ -480,7 +551,7 @@ export async function runDocsCommand(args: string[]): Promise<void> {
try {
rows = allView
? await listDocs(query, tableName, { status, limit })
: await listDocs(query, tableName, { status, projectOrLegacy: explicitProject !== undefined ? resolveProjectArg(explicitProject) : headerProject, limit });
: await listDocs(query, tableName, { status, projectOrLegacy: explicitProject !== undefined ? resolveProjectArg(explicitProject) : headerProject, limit, readerScope: explicitProject === undefined ? currentScope(defaultGit(flagValue(args, "--cwd") ?? process.cwd())) : undefined });
} catch (err) {
if (!isMissingTableError((err as Error).message)) throw err;
}
Expand Down Expand Up @@ -668,6 +739,10 @@ export async function runDocsCommand(args: string[]): Promise<void> {
if (!process.env.HIVEMIND_QUERY_TIMEOUT_MS) process.env.HIVEMIND_QUERY_TIMEOUT_MS = "30000";
const report = await runWikiRefreshCycle({
query, tableName, snap, repoRoot: cwd, project,
// Branch identity: on the trunk this is `main` (canonical corpus); on a
// feature branch it is `b:<branch>`, so the refresh reads/writes/leases
// its own overlay and never touches main.
scope: currentScope(git),
run: makeHostRunPrompt(), runPage: makeHostPageRunPrompt(), git,
owner: `${userInfo().username}@${hostname()}:${process.pid}`,
force,
Expand Down Expand Up @@ -858,6 +933,9 @@ export async function runDocsCommand(args: string[]): Promise<void> {
if (!process.env.HIVEMIND_QUERY_TIMEOUT_MS) process.env.HIVEMIND_QUERY_TIMEOUT_MS = "30000";
const report = await generateWikiPages({
query, tableName, snap, repoRoot: cwd, project,
// Branch identity for the written rows: `main` on the trunk, `b:<branch>`
// on a feature branch (a branch-scoped overlay, invisible on main).
scope: currentScope(defaultGit(cwd)),
include, exclude, existing, force, limit, concurrency,
run: makeHostRunPrompt(),
runPage: makeHostPageRunPrompt(),
Expand All @@ -869,6 +947,10 @@ export async function runDocsCommand(args: string[]): Promise<void> {
if (o.status === "created") console.log(` created ${o.doc_id} (${o.files} files, ${o.chunks} chunk${o.chunks === 1 ? "" : "s"})`);
else console.log(` ${o.status} ${o.doc_id}: ${o.reason ?? ""}`);
}
// Freshness wiring: a just-generated corpus is a still photo unless auto
// refresh is on. Offer to enable it (a human gets asked; automation only
// sees the passive hint and is never blocked on stdin).
await offerAutoRefresh(cfg, project, cwd, "this wiki");
return;
}

Expand Down Expand Up @@ -955,6 +1037,9 @@ export async function runDocsCommand(args: string[]): Promise<void> {
for (const o of report.outcomes) {
if (o.status !== "created") console.log(` ${o.status} ${o.doc_id}: ${o.reason ?? ""}`);
}
// Same freshness wiring as `docs wiki`: offer to keep these per-file docs
// fresh, or fall back to the passive hint under automation.
await offerAutoRefresh(cfg, project, cwd, "these docs");
return;
}

Expand Down
5 changes: 5 additions & 0 deletions src/deeplake-schema.ts
Original file line number Diff line number Diff line change
Expand Up @@ -204,6 +204,11 @@ export const DOCS_COLUMNS: readonly ColumnDef[] = Object.freeze([
// (written only by the elected refresh turn); `u:<user>|b:<branch>` =
// a personal branch overlay (v2, opt-in). Reads default to `main`.
{ name: "scope", sql: "TEXT NOT NULL DEFAULT 'main'" },
// Per-page source fingerprint: JSON `{file: git-blob-sha}` the page was
// generated from. Drives freshness (stale iff it differs from HEAD's), the
// overlay-divergence decision, the origin publish gate, and merge promotion.
// Read only where needed (scoped reads) so generic reads stay heal-safe.
{ name: "source_fp", sql: "TEXT NOT NULL DEFAULT '{}'" },
{ name: "version", sql: "BIGINT NOT NULL DEFAULT 1" },
{ name: "created_at", sql: "TEXT NOT NULL DEFAULT ''" },
{ name: "updated_at", sql: "TEXT NOT NULL DEFAULT ''" },
Expand Down
116 changes: 116 additions & 0 deletions src/docs/branch-scope.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,116 @@
/**
* Branch identity for doc rows — the value of the `scope` column.
*
* A doc row's `scope` encodes WHICH branch's view it belongs to:
* - `main` — the canonical, public corpus (everyone sees it).
* - `b:<branch>` — a shared overlay for a pushed feature branch (everyone
* who is on that branch sees it; others fall back to main).
*
* There is deliberately NO per-user dimension in the cloud scope. Private docs
* (generated from code that is only in a local, unpushed commit) never enter
* the shared table at all — they live in a local store. So the only scopes that
* reach Deeplake are `main` and `b:<branch>`, and a pushed branch doc is shared
* by the branch, not owned by a user (two teammates on the same branch push to
* the same origin/<branch>, so the doc converges — last write wins).
*
* NB: this `scope` (a branch view) is unrelated to the generation `--scope`
* (`file` | `symbol`), which is a granularity, not an identity.
*
* All git access goes through an injected runner so the logic is pure and unit
* testable — same seam as wiki-refresh (`args.git([...]) => string | null`).
*/

/** Injected git runner: returns stdout (trimmed by callers) or null on failure. */
export type GitRunner = (args: string[]) => string | null;

/** The canonical, public corpus scope. */
export const MAIN_SCOPE = "main";

/** Prefix marking a branch-overlay scope. */
const BRANCH_PREFIX = "b:";

/** The `scope` value for a feature branch overlay. */
export function branchScope(branch: string): string {
return `${BRANCH_PREFIX}${branch}`;
}

export type ParsedScope =
| { kind: "main" }
| { kind: "branch"; branch: string };

/** Parse a stored `scope` value back into its identity. Unknown -> main. */
export function parseScope(scope: string | undefined): ParsedScope {
if (scope && scope.startsWith(BRANCH_PREFIX)) {
return { kind: "branch", branch: scope.slice(BRANCH_PREFIX.length) };
}
return { kind: "main" };
}

/**
* Current branch name, or null when git can't answer or HEAD is detached.
* A detached HEAD has no branch identity, so it resolves to the trunk (main).
*/
export function currentBranch(git: GitRunner): string | null {
const out = git(["rev-parse", "--abbrev-ref", "HEAD"])?.trim();
if (!out || out === "HEAD") return null; // detached / no branch
return out;
}

/**
* The default branch (trunk) of the repo, from `origin/HEAD`. Falls back to
* `main` when there is no origin or the symbolic ref is unset — the safe
* default (a repo with no remote treats its work as the canonical corpus).
*/
export function trunkBranch(git: GitRunner): string {
// `refs/remotes/origin/HEAD -> refs/remotes/origin/<trunk>`
const ref = git(["symbolic-ref", "--short", "refs/remotes/origin/HEAD"])?.trim();
if (ref) {
const slash = ref.lastIndexOf("/");
const name = slash >= 0 ? ref.slice(slash + 1) : ref;
if (name) return name;
}
return "main";
}

/**
* The `scope` value for the current git checkout: `main` when on the trunk (or
* detached / non-git), else `b:<branch>`. This is the single place that maps
* "where git HEAD is" onto a doc identity.
*/
export function currentScope(git: GitRunner, trunk?: string): string {
const branch = currentBranch(git);
if (branch === null) return MAIN_SCOPE;
const trunkName = trunk ?? trunkBranch(git);
return branch === trunkName ? MAIN_SCOPE : branchScope(branch);
}

/**
* Pick the row a reader on `readerScope` should see, from all candidate rows
* for ONE doc_id across scopes. Precedence:
* reader's own branch overlay > main > (any other branch: ignored)
* Within the winning scope, the highest `version` wins.
*
* This is the read-side isolation guarantee: a reader on `b:feature` sees the
* feature overlay if it exists, else falls back to main, and NEVER another
* branch's overlay; a reader on `main` sees only `main` rows. Rows missing a
* `scope` (legacy / un-stamped) count as `main` — so pre-branch corpora keep
* resolving unchanged.
*/
export function pickByScopePrecedence<T extends { scope?: string; version: number }>(
rows: readonly T[],
readerScope: string,
): T | null {
let best: T | null = null;
let bestRank = -1;
for (const r of rows) {
const s = r.scope || MAIN_SCOPE; // empty/undefined scope resolves as main
// 2 = the reader's own scope, 1 = main fallback, 0 = a foreign branch.
const rank = s === readerScope ? 2 : s === MAIN_SCOPE ? 1 : 0;
if (rank === 0) continue; // never surface another branch's overlay
if (best === null || rank > bestRank || (rank === bestRank && r.version > best.version)) {
best = r;
bestRank = rank;
}
}
return best;
}
2 changes: 1 addition & 1 deletion src/docs/candidates.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,7 @@ import type { GraphSnapshot } from "../graph/types.js";
/** Run `git <args>` in `cwd`; returns stdout, or null on any failure. */
export type GitRunner = (args: string[]) => string | null;

function defaultGit(cwd: string): GitRunner {
export function defaultGit(cwd: string): GitRunner {
return (args) => {
try {
return execFileSync("git", ["-C", cwd, ...args], {
Expand Down
Loading