Pre-launch — Gluecron is in final validation. Public signups and git hosting for non-owner users open after launch review.
CodeIssuesPull RequestsActionsSecurityInsightsSettings
✨ AI
More
claude/adoring-hopper-5x74bqclaude/affectionate-feynman-ykrf1hclaude/architecture-audit-design-wxprenclaude/build-status-update-3MXsfclaude/charming-meitner-mllb5rclaude/compare-gate-gluecron-s4mFQclaude/confident-faraday-tikcwbclaude/continue-work-XMTlIclaude/crontech-gluecron-deploy-7MIECclaude/crontech-platform-setup-SeKfwclaude/design-2026claude/ecstatic-ptolemy-jMdigclaude/enhance-github-integration-QNHdGclaude/fix-aa-loop-issue-PonMQclaude/fix-actions-and-processclaude/fix-desktop-errors-XqoW8claude/fix-red-workflowsclaude/fix-website-access-6FKJNclaude/gatetest-integration-hardeningclaude/github-audit-improvements-bDFr9claude/gluecron-launch-status-FoMRlclaude/hopeful-lamport-olfCTclaude/issue-to-pr-and-protectionsclaude/jolly-heisenberg-2sg1Qclaude/launch-preparation-QmTb6claude/new-session-xk1l7claude/plan-platform-architecture-kkN4yclaude/platform-analysis-roadmap-1nUGLclaude/platform-launch-assessment-8dWV8claude/polish-platform-release-AeDrUclaude/resume-previous-work-KzyLwclaude/review-crontech-handoff-qYEVqclaude/review-project-completeness-lHhS2claude/review-readme-docs-ulqPKclaude/serene-edison-rj87weclaude/setup-multi-repo-dev-BCwNQclaude/ship-fixes-and-tests-Jvz1cclaude/site-audit-competitive-pctlwgclaude/site-migration-vercel-XstpKclaude/standalone-product-repos-XHFTDcopilot/feat-smart-empty-states-keyboard-first-enhancementcopilot/feat-smart-morning-digest-review-context-restorecopilot/fix-and-process-workflowscopilot/update-ai-powered-code-reviewfeat/debt-mapfeat/push-policy-codeowners-hardeningfeat/smart-digest-contextfeat/stage-impactfeat/t1-secret-migrationfeat/u-polishfeat/w-self-hostfeat/w2-claude-configfix/agent-journey-orphan-sweepfix/audit-sweep-2026-07-26gatetest/auto-fix-1776586424172gatetest/auto-fix-1776586534814gatetest/auto-fix-1776590685143gatetest/auto-fix-1776590808199mainops/redeploy-retriggerstyle/dxt-cta-themeworktree-agent-a3377aad30d55da26worktree-agent-a7ef607b7ee1d6c74
index-existing-repo.ts4.8 KB · 149 lines
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
/**
 * Semantically index a repository that arrived without a push.
 *
 * `indexChangedFiles()` is called from exactly one place: the git post-receive
 * hook. That is correct for the push path and wrong for every other way a
 * repository comes into existence:
 *
 *   - Bulk import clones with `git clone`, which never fires post-receive.
 *   - POST /new + first-repo-scaffold writes through the git plumbing API,
 *     which also never fires post-receive.
 *   - Forks copy the bare repo directly.
 *
 * So the two onboarding paths built in this session produced repositories
 * where `gluecron_semantic_search`, the repo chat, and "ask this repo" all
 * returned nothing — silently, because an empty index is indistinguishable
 * from "no matches". The agent-first experience depends entirely on that
 * index being populated, which is how this was found.
 *
 * post-receive indexes the paths that CHANGED in a push. There is no such
 * delta for a repo that just arrived, so this walks the tree at HEAD and
 * indexes what is there, then delegates to the same indexChangedFiles() the
 * hook uses — same filtering, same embedding model, same cap.
 */

import { mapWithConcurrency, DB_FANOUT_LIMIT } from "./concurrency";

export interface IndexRepoResult {
  indexed: number;
  skipped: number;
  model: string;
  error?: string;
}

export interface IndexRepoDeps {
  getTree: (
    owner: string,
    repo: string,
    ref: string,
    path?: string
  ) => Promise<Array<{ name: string; type: string; path?: string }>>;
  indexChangedFiles: (args: {
    repositoryId: string;
    ownerName: string;
    repoName: string;
    commitSha: string;
    changedPaths: string[];
  }) => Promise<{ indexed: number; skipped: number; model: string }>;
}

async function realDeps(): Promise<IndexRepoDeps> {
  const { getTree } = await import("../git/repository");
  const { indexChangedFiles } = await import("./semantic-index");
  return { getTree: getTree as IndexRepoDeps["getTree"], indexChangedFiles };
}

/** Directories never worth embedding. Keeps the walk cheap on a big import. */
const SKIP_DIRS = new Set([
  ".git", "node_modules", "dist", "build", "out", "target", "vendor",
  ".next", ".cache", "coverage", "__pycache__", ".venv", "venv",
]);

/** Depth cap. A pathological monorepo should not turn one import into a
 *  thousand tree reads; indexChangedFiles caps files independently. */
const MAX_DEPTH = 6;

/**
 * Collect blob paths at `ref`, breadth-first, bounded.
 *
 * Concurrency is bounded for the same reason as everywhere else in the
 * onboarding path: an import of 100 repos must not open an unbounded number
 * of concurrent tree reads against one disk.
 */
async function collectPaths(
  deps: IndexRepoDeps,
  owner: string,
  repo: string,
  ref: string
): Promise<string[]> {
  const files: string[] = [];
  let level: string[] = [""];

  for (let depth = 0; depth < MAX_DEPTH && level.length > 0; depth++) {
    const results = await mapWithConcurrency(level, DB_FANOUT_LIMIT, async (dir) => {
      try {
        return await deps.getTree(owner, repo, ref, dir || undefined);
      } catch {
        // A single unreadable directory must not abandon the whole index.
        return [];
      }
    });

    const next: string[] = [];
    for (let i = 0; i < results.length; i++) {
      const dir = level[i];
      for (const entry of results[i] ?? []) {
        const full = entry.path ?? (dir ? `${dir}/${entry.name}` : entry.name);
        if (entry.type === "tree") {
          if (SKIP_DIRS.has(entry.name)) continue;
          next.push(full);
        } else if (entry.type === "blob") {
          files.push(full);
        }
      }
    }
    level = next;
  }

  return files;
}

/**
 * Index a repository at `commitSha`. Never throws — an unindexed repo is a
 * degraded search experience, not a failed import or a failed repo creation.
 */
export async function indexExistingRepo(
  args: {
    repositoryId: string;
    owner: string;
    repoName: string;
    ref: string;
    commitSha: string;
  },
  injected?: IndexRepoDeps
): Promise<IndexRepoResult> {
  try {
    const deps = injected ?? (await realDeps());
    const paths = await collectPaths(deps, args.owner, args.repoName, args.ref);
    if (paths.length === 0) {
      return { indexed: 0, skipped: 0, model: "none" };
    }
    // indexChangedFiles does the filtering (isCodeFile), the cap, and the
    // embedding — reusing it keeps scaffolded, imported and pushed repos on
    // exactly the same indexing behaviour.
    return await deps.indexChangedFiles({
      repositoryId: args.repositoryId,
      ownerName: args.owner,
      repoName: args.repoName,
      commitSha: args.commitSha,
      changedPaths: paths,
    });
  } catch (err) {
    return {
      indexed: 0,
      skipped: 0,
      model: "none",
      error: err instanceof Error ? err.message : String(err),
    };
  }
}