Skip to main content

khive_pack_git/
ingest.rs

1//! Batch, cursor-based git-history ingester (ADR-088 §5). One-shot: walks
2//! local git history plus (optionally) `gh`-fetched issues and pull
3//! requests, and writes `commit` / `issue` / `pull_request` notes through
4//! the standard `create` verb. See crates/khive-pack-git/docs/api/ingest.md for
5//! the full module overview.
6
7use std::collections::HashMap;
8use std::path::{Path, PathBuf};
9use std::process::Command;
10
11use anyhow::{anyhow, Context, Result};
12use chrono::Utc;
13use serde::{Deserialize, Serialize};
14use serde_json::json;
15use uuid::Uuid;
16
17use khive_runtime::{secret_gate, KhiveRuntime, NamespaceToken, VerbRegistry};
18use khive_storage::types::{SqlStatement, SqlValue};
19
20use crate::hook;
21use crate::refs;
22
23/// Which record kinds a `run_ingest` pass processes. `Default` selects all
24/// three — the CLI's historical behavior and the `git.digest` verb's default
25/// (ADR-088 Amendment 1).
26#[derive(Debug, Clone, Copy)]
27pub struct IngestInclude {
28    pub commits: bool,
29    pub issues: bool,
30    pub pull_requests: bool,
31}
32
33impl Default for IngestInclude {
34    fn default() -> Self {
35        Self {
36            commits: true,
37            issues: true,
38            pull_requests: true,
39        }
40    }
41}
42
43/// Options for one ingest pass.
44#[derive(Debug, Clone)]
45pub struct IngestOptions {
46    /// Local path to the git repository to walk.
47    pub repo: PathBuf,
48    /// The repo-anchor `project` entity — full UUID or an 8+ hex prefix.
49    pub project: String,
50    /// Bounded work per call, counted across commits + issues + PRs
51    /// (ADR-088 Amendment 1). `None` means unbounded — the CLI's historical
52    /// one-shot behavior.
53    pub max_items: Option<u64>,
54    /// Which record kinds to ingest this pass.
55    pub include: IngestInclude,
56}
57
58impl IngestOptions {
59    /// Convenience constructor for callers that want the CLI's historical
60    /// unbounded, all-kinds behavior.
61    pub fn unbounded(repo: PathBuf, project: String) -> Self {
62        Self {
63            repo,
64            project,
65            max_items: None,
66            include: IngestInclude::default(),
67        }
68    }
69}
70
71/// Bounds new-record creation attempts across a `run_ingest` pass. See
72/// crates/khive-pack-git/docs/api/ingest.md#budget.
73struct Budget {
74    remaining: Option<u64>,
75}
76
77impl Budget {
78    fn try_consume(&mut self) -> bool {
79        match &mut self.remaining {
80            None => true,
81            Some(0) => false,
82            Some(n) => {
83                *n -= 1;
84                true
85            }
86        }
87    }
88
89    fn exhausted(&self) -> bool {
90        matches!(self.remaining, Some(0))
91    }
92}
93
94/// A newly created note this pass, for `link_references`'s
95/// same-pass cross-reference resolution. See
96/// crates/khive-pack-git/docs/api/ingest.md#newrecordforref.
97struct NewRecordForRef {
98    id: Uuid,
99    text: String,
100}
101
102/// Outcome of one ingest pass. Serializable so CLI callers can emit it as JSON.
103#[derive(Debug, Default, Serialize)]
104pub struct IngestReport {
105    pub commits_ingested: u64,
106    pub commits_skipped_existing: u64,
107    pub issues_ingested: u64,
108    pub issues_skipped_existing: u64,
109    pub prs_ingested: u64,
110    pub prs_skipped_existing: u64,
111    /// `false` when the `gh` CLI was not found on PATH — issues/PRs were
112    /// skipped but commits still ingested (ADR-088 §5 graceful-absence rule).
113    pub gh_available: bool,
114    pub warnings: Vec<String>,
115    /// `false` when `max_items` was exhausted before this pass reached the
116    /// end of every included kind's history — callers loop until `true`
117    /// (ADR-088 Amendment 1). Always `true` for an unbounded
118    /// (`max_items: None`) pass.
119    pub done: bool,
120    /// The repo-anchor `project` entity id this pass resolved (or the
121    /// verb-level caller created).
122    pub project_id: Option<String>,
123    /// `true` when the `git.digest` verb auto-created the `project` anchor
124    /// because none was found (ADR-088 Amendment 1) — never set by
125    /// `run_ingest` itself, only by the verb handler after it returns.
126    pub project_created: bool,
127    /// `annotates` edges created from a `Closes/Fixes/Resolves #N` or bare
128    /// `#N` reference in a commit message or issue/PR body to the referenced
129    /// issue/PR note (ADR-088 Amendment 1 ingest enrichment).
130    pub reference_edges_created: u64,
131    /// References that named a number this pass could not resolve to an
132    /// ingested issue/PR note within the same project — skipped, not an
133    /// error (fail-open).
134    pub reference_edges_unresolved: u64,
135    /// `precedes` edges created from a commit's `parents[]` to the commit
136    /// itself (ADR-088 Amendment 1 ingest enrichment).
137    pub parent_edges_created: u64,
138    /// Commits whose masked content exceeded `MAX_COMMIT_EMBED_BYTES`: the
139    /// full commit note was stored and FTS-indexed unchanged, but the vector
140    /// embedding input was truncated to a UTF-8-safe head prefix at the cap
141    /// (issue #764). Only incremented for successfully created commits.
142    pub commit_embeddings_truncated: u64,
143}
144
145/// Run one ingest pass over `opts.repo`: issues + PRs first (via `gh`, when
146/// available), then commits (via local `git log`), each bounded by
147/// `opts.max_items` and cursor-resumable (call again while the returned
148/// `IngestReport.done` is `false`). Returns an error only for a failure that
149/// aborts the whole pass (e.g. an unresolvable `opts.project`); per-record
150/// failures are collected in `IngestReport.warnings` instead. See
151/// crates/khive-pack-git/docs/api/ingest.md#run_ingest_with_commit_recovery for
152/// why this has no self-healing recovery (unlike the verb-handler path).
153pub async fn run_ingest(
154    runtime: &KhiveRuntime,
155    token: &NamespaceToken,
156    registry: &VerbRegistry,
157    opts: IngestOptions,
158) -> Result<IngestReport> {
159    run_ingest_with_commit_recovery(runtime, token, registry, opts, |_repo, _err| Ok(None)).await
160}
161
162/// Same one-shot ingest pass as `run_ingest`, but a classified
163/// missing-promisor-object failure is retried through `recover` (issue
164/// #765) instead of aborting the whole pass. See
165/// crates/khive-pack-git/docs/api/ingest.md#run_ingest_with_commit_recovery.
166pub(crate) async fn run_ingest_with_commit_recovery(
167    runtime: &KhiveRuntime,
168    token: &NamespaceToken,
169    registry: &VerbRegistry,
170    opts: IngestOptions,
171    mut recover: impl FnMut(&Path, &GitLogError) -> Result<Option<RecoveredRepo>> + Send,
172) -> Result<IngestReport> {
173    let mut report = IngestReport {
174        done: true,
175        ..IngestReport::default()
176    };
177
178    let project_id = resolve_id(runtime, token, &opts.project)
179        .await?
180        .ok_or_else(|| anyhow!("--project {:?} did not resolve to an entity", opts.project))?;
181    report.project_id = Some(project_id.to_string());
182
183    let mut merge_sha_to_pr: HashMap<String, Uuid> = HashMap::new();
184    let mut number_to_pr: HashMap<u64, Uuid> = HashMap::new();
185    let mut budget = Budget {
186        remaining: opts.max_items,
187    };
188    let mut new_records: Vec<NewRecordForRef> = Vec::new();
189
190    // Graceful degradation covers both "gh is not on PATH" and "gh is present
191    // but this repo has no usable GitHub remote" (e.g. a synthetic/local-only
192    // repo) — either way, issues/PRs are skipped with a warning and commits
193    // still ingest (ADR-088 §5). A hard `gh` failure must never abort the
194    // whole pass.
195    if opts.include.issues || opts.include.pull_requests {
196        if gh_available(&opts.repo) {
197            report.gh_available = true;
198            if opts.include.pull_requests && !budget.exhausted() {
199                match ingest_prs(
200                    runtime,
201                    token,
202                    registry,
203                    &opts.repo,
204                    project_id,
205                    &mut report,
206                    &mut merge_sha_to_pr,
207                    &mut number_to_pr,
208                    &mut budget,
209                    &mut new_records,
210                )
211                .await
212                {
213                    Ok(()) => {}
214                    Err(e) => report
215                        .warnings
216                        .push(format!("gh pr list failed, skipping pull requests: {e}")),
217                }
218            }
219            if opts.include.issues && !budget.exhausted() {
220                if let Err(e) = ingest_issues(
221                    runtime,
222                    token,
223                    registry,
224                    &opts.repo,
225                    project_id,
226                    &mut report,
227                    &mut budget,
228                    &mut new_records,
229                )
230                .await
231                {
232                    report
233                        .warnings
234                        .push(format!("gh issue list failed, skipping issues: {e}"));
235                }
236            }
237        } else {
238            report.gh_available = false;
239            report.warnings.push(
240                "gh CLI not found on PATH; skipped issues and pull requests — commits still ingest"
241                    .to_string(),
242            );
243        }
244    }
245
246    if opts.include.commits && !budget.exhausted() {
247        ingest_commits(
248            runtime,
249            token,
250            registry,
251            &opts.repo,
252            project_id,
253            &merge_sha_to_pr,
254            &number_to_pr,
255            &mut report,
256            &mut budget,
257            &mut new_records,
258            &mut recover,
259        )
260        .await?;
261    }
262
263    if budget.exhausted() {
264        report.done = false;
265    }
266
267    link_references(
268        runtime,
269        token,
270        registry,
271        project_id,
272        &new_records,
273        &mut report,
274    )
275    .await;
276
277    Ok(report)
278}
279
280/// Resolve a full UUID or an 8+ hex prefix to a full UUID, unfiltered by
281/// namespace.
282async fn resolve_id(
283    runtime: &KhiveRuntime,
284    _token: &NamespaceToken,
285    raw: &str,
286) -> Result<Option<Uuid>> {
287    if let Ok(u) = Uuid::parse_str(raw) {
288        return Ok(Some(u));
289    }
290    runtime
291        .resolve_prefix_unfiltered(raw)
292        .await
293        .map_err(|e| anyhow!("{e}"))
294}
295
296/// Resolve `raw` (a full UUID or an 8+ hex prefix) to an existing `project`
297/// entity id, unfiltered by namespace. Returns `Ok(None)` when no entity
298/// matches; never creates one. Used by the `git.digest` verb handler to
299/// resolve an explicitly supplied `project` argument.
300pub async fn resolve_project_id(runtime: &KhiveRuntime, raw: &str) -> Result<Option<Uuid>> {
301    if let Ok(u) = Uuid::parse_str(raw) {
302        return Ok(Some(u));
303    }
304    runtime
305        .resolve_prefix_unfiltered(raw)
306        .await
307        .map_err(|e| anyhow!("{e}"))
308}
309
310/// Find an existing `issue` or `pull_request` note by `properties.number`
311/// within `project_id`.
312async fn find_issue_or_pr_by_number(
313    runtime: &KhiveRuntime,
314    token: &NamespaceToken,
315    project_id: Uuid,
316    number: u64,
317) -> Result<Option<Uuid>> {
318    if let Some(id) = find_by_number(runtime, token, "issue", project_id, number).await? {
319        return Ok(Some(id));
320    }
321    find_by_number(runtime, token, "pull_request", project_id, number).await
322}
323
324/// Post-ingestion sweep: extract GitHub reference-grammar mentions from
325/// every note created this pass and materialize `annotates` edges to the
326/// referenced issue/PR note. Fail-open. See
327/// crates/khive-pack-git/docs/api/ingest.md#link_references.
328async fn link_references(
329    runtime: &KhiveRuntime,
330    token: &NamespaceToken,
331    registry: &VerbRegistry,
332    project_id: Uuid,
333    new_records: &[NewRecordForRef],
334    report: &mut IngestReport,
335) {
336    for record in new_records {
337        let mentions = refs::dedupe_prefer_closes(refs::extract_references(&record.text));
338        for mention in mentions {
339            let target = match find_issue_or_pr_by_number(
340                runtime,
341                token,
342                project_id,
343                mention.number,
344            )
345            .await
346            {
347                Ok(Some(id)) => id,
348                Ok(None) => {
349                    report.reference_edges_unresolved += 1;
350                    continue;
351                }
352                Err(e) => {
353                    report
354                        .warnings
355                        .push(format!("resolving reference #{}: {e}", mention.number));
356                    continue;
357                }
358            };
359            if target == record.id {
360                // A note referencing its own number (rare, e.g. a PR body
361                // that quotes its own number) — not a real cross-reference.
362                continue;
363            }
364            match registry
365                .dispatch(
366                    "link",
367                    json!({
368                        "source_id": record.id.to_string(),
369                        "target_id": target.to_string(),
370                        "relation": "annotates",
371                        "metadata": { "ref_kind": mention.kind.as_str() },
372                    }),
373                )
374                .await
375            {
376                Ok(_) => report.reference_edges_created += 1,
377                Err(e) => report.warnings.push(format!(
378                    "linking reference #{} from {}: {e}",
379                    mention.number, record.id
380                )),
381            }
382        }
383    }
384}
385
386/// `true` when `gh` is on PATH and can run inside `repo`.
387fn gh_available(repo: &Path) -> bool {
388    Command::new("gh")
389        .arg("--version")
390        .current_dir(repo)
391        .output()
392        .map(|o| o.status.success())
393        .unwrap_or(false)
394}
395
396/// Look up an existing `commit` note by its `properties.sha` (natural-key
397/// idempotence — dedupe before create).
398async fn find_commit_by_sha(
399    runtime: &KhiveRuntime,
400    token: &NamespaceToken,
401    sha: &str,
402) -> Result<Option<Uuid>> {
403    let sql = runtime.sql();
404    let mut r = sql.reader().await.map_err(|e| anyhow!("{e}"))?;
405    let row = r
406        .query_row(SqlStatement {
407            sql: "SELECT id FROM notes WHERE kind='commit' AND namespace=?1 \
408                  AND deleted_at IS NULL AND json_extract(properties,'$.sha')=?2 LIMIT 1"
409                .into(),
410            params: vec![
411                SqlValue::Text(token.namespace().as_str().to_string()),
412                SqlValue::Text(sha.to_string()),
413            ],
414            label: Some("git_ingest_find_commit_by_sha".into()),
415        })
416        .await
417        .map_err(|e| anyhow!("{e}"))?;
418    Ok(row.and_then(|r| row_uuid(&r)))
419}
420
421/// Look up an existing `issue`/`pull_request` note by its `properties.number`,
422/// scoped by kind + namespace + `project_id` (GitHub numbers are
423/// repository-scoped — see crates/khive-pack-git/docs/api/ingest.md).
424async fn find_by_number(
425    runtime: &KhiveRuntime,
426    token: &NamespaceToken,
427    kind: &str,
428    project_id: Uuid,
429    number: u64,
430) -> Result<Option<Uuid>> {
431    let sql = runtime.sql();
432    let mut r = sql.reader().await.map_err(|e| anyhow!("{e}"))?;
433    let row = r
434        .query_row(SqlStatement {
435            sql: "SELECT id FROM notes WHERE kind=?1 AND namespace=?2 \
436                  AND deleted_at IS NULL AND json_extract(properties,'$.number')=?3 \
437                  AND json_extract(properties,'$.project_id')=?4 LIMIT 1"
438                .into(),
439            params: vec![
440                SqlValue::Text(kind.to_string()),
441                SqlValue::Text(token.namespace().as_str().to_string()),
442                SqlValue::Integer(number as i64),
443                SqlValue::Text(project_id.to_string()),
444            ],
445            label: Some("git_ingest_find_by_number".into()),
446        })
447        .await
448        .map_err(|e| anyhow!("{e}"))?;
449    Ok(row.and_then(|r| row_uuid(&r)))
450}
451
452fn row_uuid(row: &khive_storage::types::SqlRow) -> Option<Uuid> {
453    match row.get("id") {
454        Some(SqlValue::Uuid(u)) => Some(*u),
455        Some(SqlValue::Text(s)) => Uuid::parse_str(s).ok(),
456        _ => None,
457    }
458}
459
460/// Escape SQLite `LIKE` wildcards (`%`, `_`, `\`) so a caller-supplied path
461/// matches literally under `LIKE ... ESCAPE '\'`.
462fn escape_like(input: &str) -> String {
463    let mut out = String::with_capacity(input.len());
464    for c in input.chars() {
465        if matches!(c, '\\' | '%' | '_') {
466            out.push('\\');
467        }
468        out.push(c);
469    }
470    out
471}
472
473/// Find an existing `document` entity whose `properties.source_uri` or
474/// `name` matches `path` (ADR-086 keying convention); `None` when no match
475/// (v0 never creates documents on the ingester's behalf). See
476/// crates/khive-pack-git/docs/api/ingest.md#find_document_for_path for the
477/// single-query exact-vs-suffix-match ordering rationale.
478async fn find_document_for_path(
479    runtime: &KhiveRuntime,
480    token: &NamespaceToken,
481    path: &str,
482) -> Result<Option<Uuid>> {
483    let file_name = Path::new(path)
484        .file_name()
485        .and_then(|f| f.to_str())
486        .unwrap_or(path);
487    let sql = runtime.sql();
488    let namespace = token.namespace().as_str().to_string();
489    let like_pattern = format!("%{}", escape_like(path));
490
491    let mut r = sql.reader().await.map_err(|e| anyhow!("{e}"))?;
492    let row = r
493        .query_row(SqlStatement {
494            sql: "SELECT id FROM entities WHERE kind='document' AND namespace=?1 \
495                  AND deleted_at IS NULL \
496                  AND (json_extract(properties,'$.source_uri')=?2 OR name=?3 \
497                       OR json_extract(properties,'$.source_uri') LIKE ?4 ESCAPE '\\') \
498                  ORDER BY CASE WHEN json_extract(properties,'$.source_uri')=?2 OR name=?3 \
499                                THEN 0 ELSE 1 END, id \
500                  LIMIT 1"
501                .into(),
502            params: vec![
503                SqlValue::Text(namespace),
504                SqlValue::Text(path.to_string()),
505                SqlValue::Text(file_name.to_string()),
506                SqlValue::Text(like_pattern),
507            ],
508            label: Some("git_ingest_find_document_for_path".into()),
509        })
510        .await
511        .map_err(|e| anyhow!("{e}"))?;
512    Ok(row.and_then(|r| row_uuid(&r)))
513}
514
515/// Read the last-ingested cursor value for `(project_id, kind)`, if any.
516async fn read_cursor(
517    runtime: &KhiveRuntime,
518    project_id: Uuid,
519    kind: &str,
520) -> Result<Option<String>> {
521    let sql = runtime.sql();
522    let mut r = sql.reader().await.map_err(|e| anyhow!("{e}"))?;
523    let row = r
524        .query_row(SqlStatement {
525            sql: "SELECT cursor_value FROM git_mirror_cursor WHERE project_id=?1 AND kind=?2"
526                .into(),
527            params: vec![
528                SqlValue::Text(project_id.to_string()),
529                SqlValue::Text(kind.to_string()),
530            ],
531            label: Some("git_ingest_read_cursor".into()),
532        })
533        .await
534        .map_err(|e| anyhow!("{e}"))?;
535    Ok(row.and_then(|r| match r.get("cursor_value") {
536        Some(SqlValue::Text(s)) => Some(s.clone()),
537        _ => None,
538    }))
539}
540
541/// Advance the `(project_id, kind)` cursor. See
542/// crates/khive-pack-git/docs/api/ingest.md#write_cursor for the
543/// stall-then-retry cursor semantics.
544async fn write_cursor(
545    runtime: &KhiveRuntime,
546    project_id: Uuid,
547    kind: &str,
548    value: &str,
549) -> Result<()> {
550    let sql = runtime.sql();
551    let mut w = sql.writer().await.map_err(|e| anyhow!("{e}"))?;
552    w.execute(SqlStatement {
553        sql: "INSERT INTO git_mirror_cursor(project_id, kind, cursor_value, updated_at) \
554              VALUES(?1, ?2, ?3, ?4) \
555              ON CONFLICT(project_id, kind) DO UPDATE SET \
556                cursor_value=excluded.cursor_value, \
557                updated_at=excluded.updated_at"
558            .into(),
559        params: vec![
560            SqlValue::Text(project_id.to_string()),
561            SqlValue::Text(kind.to_string()),
562            SqlValue::Text(value.to_string()),
563            SqlValue::Integer(Utc::now().timestamp_micros()),
564        ],
565        label: Some("git_ingest_write_cursor".into()),
566    })
567    .await
568    .map_err(|e| anyhow!("{e}"))?;
569    Ok(())
570}
571
572// ── commits ─────────────────────────────────────────────────────────────────
573
574const RECORD_SEP: char = '\u{1e}';
575const FIELD_SEP: char = '\u{1f}';
576
577struct RawCommit {
578    sha: String,
579    short_sha: String,
580    author: String,
581    author_email: String,
582    committed_at: String,
583    parents: Vec<String>,
584    subject: String,
585    body: String,
586}
587
588/// Which `git log` pass a classified failure came from (issue #765). See
589/// crates/khive-pack-git/docs/api/ingest.md#issue-765-commit-snapshot-recovery.
590#[derive(Debug, Clone, Copy, PartialEq, Eq)]
591pub(crate) enum GitLogPhase {
592    Metadata,
593    TouchedFiles,
594}
595
596/// A non-zero-exit `git log` failure, carrying its phase and raw stderr for
597/// classification by `is_missing_promisor_object`.
598#[derive(Debug)]
599pub(crate) struct GitLogError {
600    phase: GitLogPhase,
601    stderr: String,
602}
603
604impl std::fmt::Display for GitLogError {
605    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
606        let cmd = match self.phase {
607            GitLogPhase::Metadata => "git log",
608            GitLogPhase::TouchedFiles => "git log --name-only",
609        };
610        write!(f, "{cmd} failed: {}", self.stderr)
611    }
612}
613
614impl std::error::Error for GitLogError {}
615
616impl GitLogError {
617    /// `true` for exactly the class of failure issue #765 authorizes
618    /// self-healing for: a missing-object diagnostic that names a promisor
619    /// remote. Deliberately narrow — see
620    /// crates/khive-pack-git/docs/api/ingest.md#issue-765-commit-snapshot-recovery.
621    pub(crate) fn is_missing_promisor_object(&self) -> bool {
622        let lower = self.stderr.to_ascii_lowercase();
623        lower.contains("promisor")
624            && (lower.contains("not in the object database") || lower.contains("missing object"))
625    }
626}
627
628/// Walk local git history via `git log` with a stable, machine-parseable
629/// format. See crates/khive-pack-git/docs/api/ingest.md#issue-765-commit-snapshot-recovery.
630fn walk_commits(repo: &Path, since_sha: Option<&str>) -> Result<Vec<RawCommit>> {
631    // Raw control-byte separators embedded directly in the format string
632    // (not git's `%xHH` escape syntax) — passed as a single argv element
633    // (never through a shell), so the literal bytes survive intact and git's
634    // pretty-format engine emits any non-`%` character verbatim.
635    let format = format!("%H{FIELD_SEP}%h{FIELD_SEP}%an{FIELD_SEP}%ae{FIELD_SEP}%cI{FIELD_SEP}%P{FIELD_SEP}%s{FIELD_SEP}%b{RECORD_SEP}");
636    let mut args = vec![
637        "log".to_string(),
638        "--reverse".to_string(),
639        format!("--pretty=format:{format}"),
640    ];
641    if let Some(sha) = since_sha {
642        args.push(format!("{sha}..HEAD"));
643    }
644    let output = Command::new("git")
645        .arg("-C")
646        .arg(repo)
647        .args(&args)
648        .output()
649        .context("spawning git log")?;
650    if !output.status.success() {
651        return Err(anyhow::Error::new(GitLogError {
652            phase: GitLogPhase::Metadata,
653            stderr: String::from_utf8_lossy(&output.stderr).into_owned(),
654        }));
655    }
656    let text = String::from_utf8_lossy(&output.stdout);
657    let mut commits = Vec::new();
658    for record in text.split(RECORD_SEP) {
659        let record = record.trim_matches('\n');
660        if record.is_empty() {
661            continue;
662        }
663        let fields: Vec<&str> = record.splitn(8, FIELD_SEP).collect();
664        if fields.len() < 8 {
665            continue;
666        }
667        let sha = fields[0].to_string();
668        let short_sha = fields[1].to_string();
669        let author = fields[2].to_string();
670        let author_email = fields[3].to_string();
671        let committed_at = fields[4].to_string();
672        let parents = fields[5]
673            .split_whitespace()
674            .map(str::to_string)
675            .collect::<Vec<_>>();
676        let subject = fields[6].to_string();
677        let body = fields[7].trim_end_matches('\n').to_string();
678        commits.push(RawCommit {
679            sha,
680            short_sha,
681            author,
682            author_email,
683            committed_at,
684            parents,
685            subject,
686            body,
687        });
688    }
689    Ok(commits)
690}
691
692/// `sha -> \[touched paths\]` for every commit in `repo`'s history, via a
693/// separate `--name-only` pass.
694fn touched_files(repo: &Path) -> Result<HashMap<String, Vec<String>>> {
695    let output = Command::new("git")
696        .arg("-C")
697        .arg(repo)
698        .arg("log")
699        .arg("--name-only")
700        .arg(format!("--pretty=format:{RECORD_SEP}%H"))
701        .output()
702        .context("spawning git log --name-only")?;
703    if !output.status.success() {
704        return Err(anyhow::Error::new(GitLogError {
705            phase: GitLogPhase::TouchedFiles,
706            stderr: String::from_utf8_lossy(&output.stderr).into_owned(),
707        }));
708    }
709    let text = String::from_utf8_lossy(&output.stdout);
710    let mut map: HashMap<String, Vec<String>> = HashMap::new();
711    for block in text.split(RECORD_SEP) {
712        let mut lines = block.lines().filter(|l| !l.trim().is_empty());
713        let Some(sha) = lines.next() else { continue };
714        let files: Vec<String> = lines.map(str::to_string).collect();
715        map.insert(sha.trim().to_string(), files);
716    }
717    Ok(map)
718}
719
720/// The two `git log` passes a commit-ingest phase needs, loaded together so
721/// a classified failure in either one can be retried as a single unit.
722struct CommitSnapshot {
723    commits: Vec<RawCommit>,
724    files_by_sha: HashMap<String, Vec<String>>,
725}
726
727/// Load one commit-history snapshot; skips `touched_files` entirely when
728/// `walk_commits` found no new commits.
729fn load_commit_snapshot(repo: &Path, since_sha: Option<&str>) -> Result<CommitSnapshot> {
730    let commits = walk_commits(repo, since_sha)?;
731    if commits.is_empty() {
732        return Ok(CommitSnapshot {
733            commits,
734            files_by_sha: HashMap::new(),
735        });
736    }
737    let files_by_sha = touched_files(repo)?;
738    Ok(CommitSnapshot {
739        commits,
740        files_by_sha,
741    })
742}
743
744/// Which repair `RemoteCommitRecovery` (`handlers.rs`) performed. See
745/// crates/khive-pack-git/docs/api/ingest.md#issue-765-commit-snapshot-recovery.
746#[derive(Debug, Clone, Copy, PartialEq, Eq)]
747pub(crate) enum CacheRepairStrategy {
748    Refetch,
749    Reclone,
750}
751
752/// The repo path and strategy a `recover` callback used to repair a
753/// classified `GitLogError`.
754pub(crate) struct RecoveredRepo {
755    pub(crate) repo: PathBuf,
756    pub(crate) strategy: CacheRepairStrategy,
757}
758
759fn cache_repair_warning(strategy: CacheRepairStrategy) -> String {
760    match strategy {
761        CacheRepairStrategy::Refetch => {
762            "repaired corrupt remote git cache by refetching missing promisor objects".to_string()
763        }
764        CacheRepairStrategy::Reclone => {
765            "repaired corrupt remote git cache by replacing the owned clone".to_string()
766        }
767    }
768}
769
770/// Load a commit-history snapshot, retrying through `recover` when the
771/// failure is a classified missing-promisor-object error (issue #765). See
772/// crates/khive-pack-git/docs/api/ingest.md#issue-765-commit-snapshot-recovery
773/// for the retry-bound semantics.
774fn recover_commit_snapshot(
775    repo: &Path,
776    since_sha: Option<&str>,
777    mut recover: impl FnMut(&Path, &GitLogError) -> Result<Option<RecoveredRepo>>,
778) -> Result<(CommitSnapshot, Option<String>)> {
779    let mut repo_path = repo.to_path_buf();
780    let mut recovery_warning: Option<String> = None;
781    loop {
782        match load_commit_snapshot(&repo_path, since_sha) {
783            Ok(snapshot) => return Ok((snapshot, recovery_warning)),
784            Err(e) => {
785                let classified = e
786                    .downcast_ref::<GitLogError>()
787                    .filter(|g| g.is_missing_promisor_object());
788                let Some(git_log_err) = classified else {
789                    return Err(e);
790                };
791                match recover(&repo_path, git_log_err)? {
792                    Some(recovered) => {
793                        repo_path = recovered.repo;
794                        recovery_warning = Some(cache_repair_warning(recovered.strategy));
795                    }
796                    None => return Err(e),
797                }
798            }
799        }
800    }
801}
802
803/// Squash-merge subject suffix `"... (#123)"` -> `123`.
804fn squash_merge_pr_number(subject: &str) -> Option<u64> {
805    let trimmed = subject.trim_end();
806    let close = trimmed.strip_suffix(')')?;
807    let open = close.rfind("(#")?;
808    close[open + 2..].parse::<u64>().ok()
809}
810
811/// Max characters for the `name` field the amendment's readable-names rider
812/// sets on newly ingested notes (issues/PRs: `"#<number> <title>"`; commits:
813/// `"<short_sha> <subject>"`).
814const NAME_MAX_CHARS: usize = 120;
815
816/// Cap for the text a commit note sends to the vector embedder (issue #764).
817/// Matches the repository's existing `MAX_EMBED_BYTES` precedent
818/// (`khive-pack-knowledge`, `kkernel::reindex`, ADR-048) — bytes, not chars,
819/// UTF-8-boundary-safe. The full, untruncated commit content is always
820/// stored and FTS-indexed; only the candidate vector input is capped.
821const MAX_COMMIT_EMBED_BYTES: usize = 32_768;
822
823/// Returns a UTF-8-valid, proper head prefix of `content` when it exceeds
824/// `MAX_COMMIT_EMBED_BYTES`, or `None` when `content` is at or under the cap
825/// (nothing to truncate — the full text is a valid embedding input as-is).
826fn truncated_embedding_head(content: &str) -> Option<&str> {
827    if content.len() <= MAX_COMMIT_EMBED_BYTES {
828        return None;
829    }
830    let mut end = MAX_COMMIT_EMBED_BYTES;
831    while !content.is_char_boundary(end) {
832        end -= 1;
833    }
834    Some(&content[..end])
835}
836
837/// Every `RawCommit` string field funnels through this constructor before it
838/// can reach `properties` or the note `name`, masking secrets in
839/// caller-controlled prose fields. See
840/// crates/khive-pack-git/docs/api/ingest.md#masking-boundaries-maskedcommitfields-maskedissuefields-maskedprfields.
841struct MaskedCommitFields {
842    sha: String,
843    short_sha: String,
844    author: String,
845    author_email: String,
846    committed_at: String,
847    parents: Vec<String>,
848    subject: String,
849    body: String,
850}
851
852impl MaskedCommitFields {
853    fn new(commit: &RawCommit) -> Self {
854        let RawCommit {
855            sha,
856            short_sha,
857            author,
858            author_email,
859            committed_at,
860            parents,
861            subject,
862            body,
863        } = commit;
864        Self {
865            sha: sha.clone(),
866            short_sha: short_sha.clone(),
867            author: secret_gate::mask_secrets(author).into_owned(),
868            author_email: secret_gate::mask_secrets(author_email).into_owned(),
869            committed_at: committed_at.clone(),
870            parents: parents.clone(),
871            subject: secret_gate::mask_secrets(subject).into_owned(),
872            body: secret_gate::mask_secrets(body).into_owned(),
873        }
874    }
875}
876
877#[allow(clippy::too_many_arguments)]
878async fn ingest_commits(
879    runtime: &KhiveRuntime,
880    token: &NamespaceToken,
881    registry: &VerbRegistry,
882    repo: &Path,
883    project_id: Uuid,
884    merge_sha_to_pr: &HashMap<String, Uuid>,
885    number_to_pr: &HashMap<u64, Uuid>,
886    report: &mut IngestReport,
887    budget: &mut Budget,
888    new_records: &mut Vec<NewRecordForRef>,
889    recover: &mut (dyn FnMut(&Path, &GitLogError) -> Result<Option<RecoveredRepo>> + Send),
890) -> Result<()> {
891    let since = read_cursor(runtime, project_id, "commits").await?;
892    let (snapshot, recovery_warning) = recover_commit_snapshot(repo, since.as_deref(), recover)?;
893    let CommitSnapshot {
894        commits,
895        files_by_sha,
896    } = snapshot;
897    if commits.is_empty() {
898        if let Some(warning) = recovery_warning {
899            report.warnings.push(warning);
900        }
901        return Ok(());
902    }
903
904    // `cursor_stalled` freezes `last_sha` at the last contiguous successfully
905    // processed commit: once a record fails to create, later records in this
906    // same pass are still attempted (so a run surfaces every failure it can,
907    // not just the first) but the cursor no longer advances past them. That
908    // guarantees a failed record is retried — and its warning re-surfaced —
909    // on every subsequent pass until it is fixed upstream, rather than being
910    // silently skipped forever because the cursor moved past it. Records that
911    // do succeed after a stall are still written (idempotent via the
912    // sha natural key), so a retried pass never double-creates them.
913    let mut last_sha: Option<String> = since;
914    let mut cursor_stalled = false;
915    // Parent SHA -> note id for commits created earlier THIS pass (walked
916    // oldest-first) — combined with `find_commit_by_sha`'s DB lookup below,
917    // this resolves parent edges regardless of which pass the parent landed
918    // in.
919    let mut local_sha_to_id: HashMap<String, Uuid> = HashMap::new();
920    for c in &commits {
921        if let Some(existing) = find_commit_by_sha(runtime, token, &c.sha).await? {
922            local_sha_to_id.insert(c.sha.clone(), existing);
923            report.commits_skipped_existing += 1;
924            if !cursor_stalled {
925                last_sha = Some(c.sha.clone());
926            }
927            continue;
928        }
929
930        if budget.exhausted() {
931            break;
932        }
933
934        let masked = MaskedCommitFields::new(c);
935        let content = if masked.body.trim().is_empty() {
936            masked.subject.clone()
937        } else {
938            format!("{}\n\n{}", masked.subject, masked.body)
939        };
940
941        let mut annotates = vec![project_id.to_string()];
942
943        if let Some(paths) = files_by_sha.get(&c.sha) {
944            for p in paths {
945                if !p.starts_with("docs/adr/") {
946                    continue;
947                }
948                if let Some(doc_id) = find_document_for_path(runtime, token, p).await? {
949                    annotates.push(doc_id.to_string());
950                }
951            }
952        }
953
954        // Merge-commit sha mapping and squash-merge suffix parsing are both
955        // scoped to PRs discovered THIS pass; also fall back to a direct
956        // by-number lookup so a commit can still resolve its merging PR when
957        // that PR was ingested in an earlier pass (its note already exists,
958        // but this run's `number_to_pr` in-memory map starts empty).
959        let pr_id = match merge_sha_to_pr.get(&c.sha).copied() {
960            Some(id) => Some(id),
961            None => match squash_merge_pr_number(&c.subject) {
962                Some(n) => match number_to_pr.get(&n).copied() {
963                    Some(id) => Some(id),
964                    None => find_by_number(runtime, token, "pull_request", project_id, n).await?,
965                },
966                None => None,
967            },
968        };
969        if let Some(pr_id) = pr_id {
970            annotates.push(pr_id.to_string());
971        }
972
973        let properties = json!({
974            "sha": masked.sha,
975            "short_sha": masked.short_sha,
976            "author": masked.author,
977            "author_email": masked.author_email,
978            "committed_at": masked.committed_at,
979            "parents": masked.parents,
980        });
981
982        let name = refs::truncate_chars(
983            &format!("{} {}", masked.short_sha, masked.subject),
984            NAME_MAX_CHARS,
985        );
986        let embedding_head = truncated_embedding_head(&content);
987
988        let mut create_request = json!({
989            "kind": "commit",
990            "name": name,
991            "content": content,
992            "properties": properties,
993            "annotates": annotates,
994        });
995        if let Some(head) = embedding_head {
996            create_request["embedding_content"] = json!(head);
997        }
998
999        budget.try_consume();
1000        match registry.dispatch("create", create_request).await {
1001            Ok(v) => {
1002                report.commits_ingested += 1;
1003                if embedding_head.is_some() {
1004                    report.commit_embeddings_truncated += 1;
1005                }
1006                if !cursor_stalled {
1007                    last_sha = Some(c.sha.clone());
1008                }
1009                if let Some(id) = v
1010                    .get("id")
1011                    .and_then(|v| v.as_str())
1012                    .and_then(|s| Uuid::parse_str(s).ok())
1013                {
1014                    local_sha_to_id.insert(c.sha.clone(), id);
1015                    new_records.push(NewRecordForRef {
1016                        id,
1017                        text: content.clone(),
1018                    });
1019                    // Parent -> child `precedes` edges (ADR-088 Amendment 1
1020                    // ingest enrichment). Fail-open: an unresolved or
1021                    // failing parent link is skipped/warned, never aborts
1022                    // the pass.
1023                    for parent_sha in &c.parents {
1024                        let parent_id = match local_sha_to_id.get(parent_sha).copied() {
1025                            Some(pid) => Some(pid),
1026                            None => find_commit_by_sha(runtime, token, parent_sha).await?,
1027                        };
1028                        let Some(parent_id) = parent_id else {
1029                            continue;
1030                        };
1031                        if parent_id == id {
1032                            continue;
1033                        }
1034                        match registry
1035                            .dispatch(
1036                                "link",
1037                                json!({
1038                                    "source_id": parent_id.to_string(),
1039                                    "target_id": id.to_string(),
1040                                    "relation": "precedes",
1041                                }),
1042                            )
1043                            .await
1044                        {
1045                            Ok(_) => report.parent_edges_created += 1,
1046                            Err(e) => report.warnings.push(format!(
1047                                "linking parent {parent_sha} -> {} precedes: {e}",
1048                                c.sha
1049                            )),
1050                        }
1051                    }
1052                }
1053            }
1054            Err(e) => {
1055                report
1056                    .warnings
1057                    .push(format!("create commit {}: {e}", c.sha));
1058                cursor_stalled = true;
1059            }
1060        }
1061    }
1062
1063    if let Some(sha) = last_sha {
1064        write_cursor(runtime, project_id, "commits", &sha).await?;
1065    }
1066    if let Some(warning) = recovery_warning {
1067        report.warnings.push(warning);
1068    }
1069    Ok(())
1070}
1071
1072// ── issues + PRs (gh CLI) ───────────────────────────────────────────────────
1073
1074#[derive(Debug, Deserialize)]
1075struct GhAuthor {
1076    login: Option<String>,
1077}
1078
1079#[derive(Debug, Deserialize)]
1080struct GhLabel {
1081    name: String,
1082}
1083
1084#[derive(Debug, Deserialize)]
1085struct GhIssue {
1086    number: u64,
1087    title: String,
1088    author: Option<GhAuthor>,
1089    #[serde(rename = "createdAt")]
1090    created_at: Option<String>,
1091    #[serde(rename = "closedAt")]
1092    closed_at: Option<String>,
1093    #[serde(rename = "updatedAt")]
1094    updated_at: Option<String>,
1095    labels: Option<Vec<GhLabel>>,
1096    #[serde(rename = "stateReason")]
1097    state_reason: Option<String>,
1098    body: Option<String>,
1099}
1100
1101#[derive(Debug, Deserialize)]
1102struct GhMergeCommit {
1103    oid: Option<String>,
1104}
1105
1106#[derive(Debug, Deserialize)]
1107struct GhPr {
1108    number: u64,
1109    title: String,
1110    author: Option<GhAuthor>,
1111    #[serde(rename = "createdAt")]
1112    created_at: Option<String>,
1113    #[serde(rename = "mergedAt")]
1114    merged_at: Option<String>,
1115    #[serde(rename = "closedAt")]
1116    closed_at: Option<String>,
1117    #[serde(rename = "updatedAt")]
1118    updated_at: Option<String>,
1119    #[serde(rename = "baseRefName")]
1120    base_ref_name: Option<String>,
1121    #[serde(rename = "headRefName")]
1122    head_ref_name: Option<String>,
1123    #[serde(rename = "mergeCommit")]
1124    merge_commit: Option<GhMergeCommit>,
1125    body: Option<String>,
1126}
1127
1128/// Every `GhIssue` field funnels through this constructor before it can
1129/// reach `properties`/`content`/the note name/the paging cursor. See
1130/// crates/khive-pack-git/docs/api/ingest.md#masking-boundaries-maskedcommitfields-maskedissuefields-maskedprfields.
1131struct MaskedIssueFields {
1132    number: u64,
1133    title: String,
1134    body: String,
1135    author_login: Option<String>,
1136    labels: Vec<String>,
1137    created_at: Option<String>,
1138    closed_at: Option<String>,
1139    updated_at: Option<String>,
1140    state_reason: StateReasonField,
1141}
1142
1143/// Classified outcome of parsing a raw `stateReason` against the governed
1144/// enum. `Rejected` never carries the raw string forward. See
1145/// crates/khive-pack-git/docs/api/ingest.md#masking-boundaries-maskedcommitfields-maskedissuefields-maskedprfields.
1146#[derive(Debug, Clone, PartialEq, Eq)]
1147enum StateReasonField {
1148    Absent,
1149    Valid(String),
1150    Rejected,
1151}
1152
1153impl MaskedIssueFields {
1154    fn new(issue: GhIssue, warnings: &mut Vec<String>) -> Self {
1155        let GhIssue {
1156            number,
1157            title,
1158            author,
1159            created_at,
1160            closed_at,
1161            updated_at,
1162            labels,
1163            state_reason,
1164            body,
1165        } = issue;
1166
1167        Self {
1168            number,
1169            title: secret_gate::mask_secrets(&title).into_owned(),
1170            body: secret_gate::mask_secrets(&body.unwrap_or_default()).into_owned(),
1171            author_login: author
1172                .and_then(|a| a.login)
1173                .map(|login| secret_gate::mask_secrets(&login).into_owned()),
1174            labels: labels
1175                .unwrap_or_default()
1176                .into_iter()
1177                .map(|l| secret_gate::mask_secrets(&l.name).into_owned())
1178                .collect(),
1179            created_at: canonical_issue_timestamp("createdAt", number, created_at, warnings),
1180            closed_at: canonical_issue_timestamp("closedAt", number, closed_at, warnings),
1181            updated_at: canonical_issue_timestamp("updatedAt", number, updated_at, warnings),
1182            state_reason: canonical_issue_state_reason(state_reason),
1183        }
1184    }
1185}
1186
1187/// Classifies a raw `stateReason` string against the governed enum
1188/// (`hook::ISSUE_STATE_REASONS`, ADR-088 §3), case-normalized first. See
1189/// crates/khive-pack-git/docs/api/ingest.md#masking-boundaries-maskedcommitfields-maskedissuefields-maskedprfields.
1190fn canonical_issue_state_reason(raw: Option<String>) -> StateReasonField {
1191    let Some(raw) = raw.filter(|r| !r.is_empty()) else {
1192        return StateReasonField::Absent;
1193    };
1194    let lowered = raw.to_ascii_lowercase();
1195    if hook::ISSUE_STATE_REASONS.contains(&lowered.as_str()) {
1196        StateReasonField::Valid(lowered)
1197    } else {
1198        StateReasonField::Rejected
1199    }
1200}
1201
1202/// Parses a GitHub issue timestamp into canonical RFC3339 form; on parse
1203/// failure the field is dropped (with a warning, never the raw value) and
1204/// the issue is still ingested. See
1205/// crates/khive-pack-git/docs/api/ingest.md#masking-boundaries-maskedcommitfields-maskedissuefields-maskedprfields.
1206fn canonical_issue_timestamp(
1207    field: &'static str,
1208    number: u64,
1209    raw: Option<String>,
1210    warnings: &mut Vec<String>,
1211) -> Option<String> {
1212    let raw = raw?;
1213    match chrono::DateTime::parse_from_rfc3339(&raw) {
1214        Ok(dt) => Some(
1215            dt.with_timezone(&Utc)
1216                .to_rfc3339_opts(chrono::SecondsFormat::Secs, true),
1217        ),
1218        Err(_) => {
1219            warnings.push(format!(
1220                "issue #{number}: {field} is not a valid RFC3339 timestamp, field dropped"
1221            ));
1222            None
1223        }
1224    }
1225}
1226
1227fn gh_json(repo: &Path, args: &[&str]) -> Result<String> {
1228    // gh has no `-C` flag (unlike git) — repo targeting is via working directory.
1229    let output = Command::new("gh")
1230        .current_dir(repo)
1231        .args(args)
1232        .output()
1233        .context("spawning gh")?;
1234    if !output.status.success() {
1235        return Err(anyhow!(
1236            "gh {:?} failed: {}",
1237            args,
1238            String::from_utf8_lossy(&output.stderr)
1239        ));
1240    }
1241    Ok(String::from_utf8_lossy(&output.stdout).into_owned())
1242}
1243
1244/// Per-page fetch cap for both PR and issue paging — `gh {pr,issue} list
1245/// --search` never returns more than this many results for a single query
1246/// regardless of `--limit`. See
1247/// crates/khive-pack-git/docs/api/ingest.md#paging-pageoutcome-decide_page_outcome-page_limit.
1248const PAGE_LIMIT: usize = 1000;
1249
1250/// What a paging loop should do after processing one fetched page — the
1251/// entire "was the remote window proven exhausted" decision lives here. See
1252/// crates/khive-pack-git/docs/api/ingest.md#paging-pageoutcome-decide_page_outcome-page_limit.
1253#[derive(Debug, Clone, PartialEq, Eq)]
1254enum PageOutcome {
1255    /// Page held fewer than `PAGE_LIMIT` items: remote window proven exhausted.
1256    WindowComplete,
1257    /// Page was full and the local budget is exhausted: stop, not proven exhausted.
1258    StopBudgetExhausted,
1259    /// Page was full but the floor didn't advance: stop, not proven exhausted.
1260    StopFloorStalled,
1261    /// Page was full, budget remains, floor advanced: fetch the next page.
1262    Continue(String),
1263}
1264
1265fn decide_page_outcome(
1266    page_len: usize,
1267    current_floor: Option<&str>,
1268    last_updated_at: Option<&str>,
1269    budget_exhausted: bool,
1270) -> PageOutcome {
1271    if page_len < PAGE_LIMIT {
1272        return PageOutcome::WindowComplete;
1273    }
1274    if budget_exhausted {
1275        return PageOutcome::StopBudgetExhausted;
1276    }
1277    match last_updated_at {
1278        Some(next) if Some(next) != current_floor => PageOutcome::Continue(next.to_string()),
1279        _ => PageOutcome::StopFloorStalled,
1280    }
1281}
1282
1283/// Test-only helper: production code matches on `PageOutcome` directly.
1284#[cfg(test)]
1285fn page_outcome_proves_window_complete(outcome: PageOutcome) -> bool {
1286    matches!(outcome, PageOutcome::WindowComplete)
1287}
1288
1289fn search_query(floor: Option<&str>) -> String {
1290    match floor {
1291        Some(f) => format!("sort:updated-asc updated:>={f}"),
1292        None => "sort:updated-asc".to_string(),
1293    }
1294}
1295
1296const PR_FIELDS: &str = "number,title,author,createdAt,mergedAt,closedAt,updatedAt,baseRefName,headRefName,mergeCommit,body";
1297const ISSUE_FIELDS: &str =
1298    "number,title,author,createdAt,closedAt,updatedAt,labels,stateReason,body";
1299
1300fn fetch_pr_page(repo: &Path, floor: Option<&str>) -> Result<Vec<GhPr>> {
1301    let search = search_query(floor);
1302    let raw = gh_json(
1303        repo,
1304        &[
1305            "pr",
1306            "list",
1307            "--state",
1308            "all",
1309            "--search",
1310            search.as_str(),
1311            "--limit",
1312            "1000",
1313            "--json",
1314            PR_FIELDS,
1315        ],
1316    )?;
1317    serde_json::from_str(&raw).context("parsing gh pr list --json")
1318}
1319
1320fn fetch_issue_page(repo: &Path, floor: Option<&str>) -> Result<Vec<GhIssue>> {
1321    let search = search_query(floor);
1322    let raw = gh_json(
1323        repo,
1324        &[
1325            "issue",
1326            "list",
1327            "--state",
1328            "all",
1329            "--search",
1330            search.as_str(),
1331            "--limit",
1332            "1000",
1333            "--json",
1334            ISSUE_FIELDS,
1335        ],
1336    )?;
1337    serde_json::from_str(&raw).context("parsing gh issue list --json")
1338}
1339
1340/// Every `GhPr` field funnels through this constructor before it can reach
1341/// `properties`/`content`/the note `name`/the in-memory PR-linking maps,
1342/// masking secrets in contributor-controlled prose fields. See
1343/// crates/khive-pack-git/docs/api/ingest.md#masking-boundaries-maskedcommitfields-maskedissuefields-maskedprfields.
1344struct MaskedPrFields {
1345    number: u64,
1346    title: String,
1347    body: String,
1348    author_login: Option<String>,
1349    created_at: Option<String>,
1350    merged_at: Option<String>,
1351    closed_at: Option<String>,
1352    updated_at: Option<String>,
1353    base_ref_name: Option<String>,
1354    head_ref_name: Option<String>,
1355    merge_commit_oid: Option<String>,
1356}
1357
1358impl MaskedPrFields {
1359    fn new(pr: GhPr) -> Self {
1360        let GhPr {
1361            number,
1362            title,
1363            author,
1364            created_at,
1365            merged_at,
1366            closed_at,
1367            updated_at,
1368            base_ref_name,
1369            head_ref_name,
1370            merge_commit,
1371            body,
1372        } = pr;
1373        Self {
1374            number,
1375            title: secret_gate::mask_secrets(&title).into_owned(),
1376            body: secret_gate::mask_secrets(&body.unwrap_or_default()).into_owned(),
1377            author_login: author
1378                .and_then(|a| a.login)
1379                .map(|login| secret_gate::mask_secrets(&login).into_owned()),
1380            created_at,
1381            merged_at,
1382            closed_at,
1383            updated_at,
1384            base_ref_name: base_ref_name.map(|r| secret_gate::mask_secrets(&r).into_owned()),
1385            head_ref_name: head_ref_name.map(|r| secret_gate::mask_secrets(&r).into_owned()),
1386            merge_commit_oid: merge_commit.and_then(|m| m.oid),
1387        }
1388    }
1389}
1390
1391#[allow(clippy::too_many_arguments)]
1392async fn ingest_prs(
1393    runtime: &KhiveRuntime,
1394    token: &NamespaceToken,
1395    registry: &VerbRegistry,
1396    repo: &Path,
1397    project_id: Uuid,
1398    report: &mut IngestReport,
1399    merge_sha_to_pr: &mut HashMap<String, Uuid>,
1400    number_to_pr: &mut HashMap<u64, Uuid>,
1401    budget: &mut Budget,
1402    new_records: &mut Vec<NewRecordForRef>,
1403) -> Result<()> {
1404    let since = read_cursor(runtime, project_id, "prs").await?;
1405
1406    // `cursor_stalled` mirrors `ingest_commits`: once one PR fails to create,
1407    // later PRs in this pass are still attempted (so every failure surfaces
1408    // in this pass's warnings), but `max_updated` no longer advances past the
1409    // stall point — the next pass re-fetches from before the failure and
1410    // retries it, while already-landed PRs are no-ops via the natural key.
1411    let mut max_updated: Option<String> = since.clone();
1412    let mut cursor_stalled = false;
1413    let mut floor = since.clone();
1414    let mut window_complete = true;
1415
1416    'paging: loop {
1417        let mut page = fetch_pr_page(repo, floor.as_deref())?;
1418        let page_len = page.len();
1419        // Each page is already `sort:updated-asc` server-side, but `--search`
1420        // makes no hard ordering guarantee across ties — re-sort defensively
1421        // so the frozen-cursor invariant (records walked in nondecreasing
1422        // `updated_at` order) holds regardless. `is_new` below is inclusive
1423        // (`updated >= cursor`) for exactly the tie reason documented at
1424        // length in the pre-pagination version of this function (a
1425        // successful and a failing record sharing one `updated_at` must both
1426        // be re-examined next pass until the cursor moves past that tie).
1427        page.sort_by(|a, b| a.updated_at.cmp(&b.updated_at));
1428        let last_updated_at = page.last().and_then(|pr| pr.updated_at.clone());
1429
1430        for pr in page {
1431            let is_new = since
1432                .as_deref()
1433                .zip(pr.updated_at.as_deref())
1434                .map(|(cursor, updated)| updated >= cursor)
1435                .unwrap_or(true);
1436
1437            if let Some(existing) =
1438                find_by_number(runtime, token, "pull_request", project_id, pr.number).await?
1439            {
1440                number_to_pr.insert(pr.number, existing);
1441                if let Some(oid) = pr.merge_commit.as_ref().and_then(|m| m.oid.clone()) {
1442                    merge_sha_to_pr.insert(oid, existing);
1443                }
1444                report.prs_skipped_existing += 1;
1445                if !cursor_stalled {
1446                    if let Some(u) = &pr.updated_at {
1447                        if max_updated
1448                            .as_deref()
1449                            .map(|m| u.as_str() > m)
1450                            .unwrap_or(true)
1451                        {
1452                            max_updated = Some(u.clone());
1453                        }
1454                    }
1455                }
1456                continue;
1457            }
1458            if !is_new {
1459                continue;
1460            }
1461            if budget.exhausted() {
1462                break;
1463            }
1464
1465            let masked = MaskedPrFields::new(pr);
1466            let content = masked.body;
1467            let properties = json!({
1468                "number": masked.number,
1469                "title": masked.title,
1470                "author": masked.author_login,
1471                "created_at": masked.created_at,
1472                "merged_at": masked.merged_at,
1473                "closed_at": masked.closed_at,
1474                "base_ref": masked.base_ref_name,
1475                "head_ref": masked.head_ref_name,
1476                "project_id": project_id.to_string(),
1477            });
1478            let name = refs::truncate_chars(
1479                &format!("#{} {}", masked.number, masked.title),
1480                NAME_MAX_CHARS,
1481            );
1482
1483            budget.try_consume();
1484            let result = match registry
1485                .dispatch(
1486                    "create",
1487                    json!({
1488                        "kind": "pull_request",
1489                        "name": name,
1490                        "content": content,
1491                        "properties": properties,
1492                        "annotates": [project_id.to_string()],
1493                    }),
1494                )
1495                .await
1496            {
1497                Ok(v) => v,
1498                Err(e) => {
1499                    report
1500                        .warnings
1501                        .push(format!("create pull_request #{}: {e}", masked.number));
1502                    cursor_stalled = true;
1503                    continue;
1504                }
1505            };
1506
1507            if let Some(id) = result
1508                .get("id")
1509                .and_then(|v| v.as_str())
1510                .and_then(|s| Uuid::parse_str(s).ok())
1511            {
1512                number_to_pr.insert(masked.number, id);
1513                if let Some(oid) = masked.merge_commit_oid {
1514                    merge_sha_to_pr.insert(oid, id);
1515                }
1516                new_records.push(NewRecordForRef {
1517                    id,
1518                    text: content.clone(),
1519                });
1520            }
1521            report.prs_ingested += 1;
1522            if !cursor_stalled {
1523                if let Some(u) = &masked.updated_at {
1524                    if max_updated
1525                        .as_deref()
1526                        .map(|m| u.as_str() > m)
1527                        .unwrap_or(true)
1528                    {
1529                        max_updated = Some(u.clone());
1530                    }
1531                }
1532            }
1533        }
1534
1535        match decide_page_outcome(
1536            page_len,
1537            floor.as_deref(),
1538            last_updated_at.as_deref(),
1539            budget.exhausted(),
1540        ) {
1541            PageOutcome::WindowComplete => break 'paging,
1542            PageOutcome::StopBudgetExhausted | PageOutcome::StopFloorStalled => {
1543                window_complete = false;
1544                break 'paging;
1545            }
1546            PageOutcome::Continue(next_floor) => floor = Some(next_floor),
1547        }
1548    }
1549
1550    if !window_complete {
1551        // The remote window may hold more PRs than this pass ever fetched
1552        // (ADR-088 Amendment 1); the local budget alone is
1553        // not a complete signal; report `done = false` regardless of budget
1554        // state so the caller's resume loop keeps going.
1555        report.done = false;
1556    }
1557
1558    if let Some(cursor) = max_updated {
1559        write_cursor(runtime, project_id, "prs", &cursor).await?;
1560    }
1561    Ok(())
1562}
1563
1564#[allow(clippy::too_many_arguments)]
1565async fn ingest_issues(
1566    runtime: &KhiveRuntime,
1567    token: &NamespaceToken,
1568    registry: &VerbRegistry,
1569    repo: &Path,
1570    project_id: Uuid,
1571    report: &mut IngestReport,
1572    budget: &mut Budget,
1573    new_records: &mut Vec<NewRecordForRef>,
1574) -> Result<()> {
1575    let since = read_cursor(runtime, project_id, "issues").await?;
1576
1577    // `cursor_stalled` mirrors `ingest_commits`/`ingest_prs`: a per-record
1578    // create failure is aggregated as a warning and later records in this
1579    // pass are still attempted, but `max_updated` freezes at the stall point
1580    // so the next pass retries the failed record instead of skipping it
1581    // forever; already-landed records are no-ops via the natural key.
1582    let mut max_updated: Option<String> = since.clone();
1583    let mut cursor_stalled = false;
1584    let mut floor = since.clone();
1585    let mut window_complete = true;
1586
1587    'paging: loop {
1588        let page = fetch_issue_page(repo, floor.as_deref())?;
1589        let page_len = page.len();
1590        // The ENTIRE fetched page is classified (masked strings, canonicalized
1591        // timestamps, governed-enum `state_reason`) before anything else --
1592        // including the sort and the paging cursor derivation below -- touches
1593        // it. A raw `GhIssue.updated_at` must never reach the sort comparator,
1594        // `last_updated_at`, or (via `decide_page_outcome`'s `Continue`) a
1595        // future `gh --search updated:>=` argument (a
1596        // credential-shaped `updatedAt` could otherwise sort last and leak
1597        // into process arguments through the paging floor).
1598        let mut masked_page: Vec<MaskedIssueFields> = page
1599            .into_iter()
1600            .map(|issue| MaskedIssueFields::new(issue, &mut report.warnings))
1601            .collect();
1602        // See `ingest_prs`: the frozen-cursor retry guarantee requires
1603        // walking records in nondecreasing updated_at order, which `--search
1604        // sort:updated-asc` does not itself guarantee across ties — sort
1605        // defensively, using the canonicalized (not raw) timestamp.
1606        masked_page.sort_by(|a, b| a.updated_at.cmp(&b.updated_at));
1607        let last_updated_at = masked_page.last().and_then(|i| i.updated_at.clone());
1608
1609        for masked in masked_page {
1610            let is_new = since
1611                .as_deref()
1612                .zip(masked.updated_at.as_deref())
1613                .map(|(cursor, updated)| updated >= cursor)
1614                .unwrap_or(true);
1615
1616            if find_by_number(runtime, token, "issue", project_id, masked.number)
1617                .await?
1618                .is_some()
1619            {
1620                report.issues_skipped_existing += 1;
1621                if !cursor_stalled {
1622                    if let Some(u) = &masked.updated_at {
1623                        if max_updated
1624                            .as_deref()
1625                            .map(|m| u.as_str() > m)
1626                            .unwrap_or(true)
1627                        {
1628                            max_updated = Some(u.clone());
1629                        }
1630                    }
1631                }
1632                continue;
1633            }
1634            if !is_new {
1635                continue;
1636            }
1637            if budget.exhausted() {
1638                break;
1639            }
1640
1641            let number = masked.number;
1642            let updated_at = masked.updated_at.clone();
1643
1644            // `stateReason` was already parsed into the governed enum at the
1645            // masking boundary (`canonical_issue_state_reason`). An ungoverned
1646            // value is rejected here, before the record is ever built or
1647            // dispatched -- the warning names only the field, never the raw
1648            // (possibly credential-shaped) value, matching ADR-088's
1649            // fail-closed/no-silent-coercion contract while preserving the
1650            // per-record warn-and-skip / frozen-cursor-retry behavior shared
1651            // with every other create-failure path in this loop.
1652            if masked.state_reason == StateReasonField::Rejected {
1653                report.warnings.push(format!(
1654                    "issue #{number}: stateReason is not one of the governed values, record skipped"
1655                ));
1656                cursor_stalled = true;
1657                continue;
1658            }
1659
1660            let content = masked.body;
1661            let safe_title = masked.title;
1662            let mut properties = json!({
1663                "number": number,
1664                "title": safe_title,
1665                "author": masked.author_login,
1666                "created_at": masked.created_at,
1667                "closed_at": masked.closed_at,
1668                "labels": masked.labels,
1669                "project_id": project_id.to_string(),
1670            });
1671            if let StateReasonField::Valid(reason) = masked.state_reason {
1672                properties["state_reason"] = json!(reason);
1673            }
1674            let name = refs::truncate_chars(&format!("#{number} {safe_title}"), NAME_MAX_CHARS);
1675
1676            budget.try_consume();
1677            let result = match registry
1678                .dispatch(
1679                    "create",
1680                    json!({
1681                        "kind": "issue",
1682                        "name": name,
1683                        "content": content,
1684                        "properties": properties,
1685                        "annotates": [project_id.to_string()],
1686                    }),
1687                )
1688                .await
1689            {
1690                Ok(v) => v,
1691                Err(e) => {
1692                    report.warnings.push(format!("create issue #{number}: {e}"));
1693                    cursor_stalled = true;
1694                    continue;
1695                }
1696            };
1697            if let Some(id) = result
1698                .get("id")
1699                .and_then(|v| v.as_str())
1700                .and_then(|s| Uuid::parse_str(s).ok())
1701            {
1702                new_records.push(NewRecordForRef {
1703                    id,
1704                    text: content.clone(),
1705                });
1706            }
1707
1708            report.issues_ingested += 1;
1709            if !cursor_stalled {
1710                if let Some(u) = &updated_at {
1711                    if max_updated
1712                        .as_deref()
1713                        .map(|m| u.as_str() > m)
1714                        .unwrap_or(true)
1715                    {
1716                        max_updated = Some(u.clone());
1717                    }
1718                }
1719            }
1720        }
1721
1722        match decide_page_outcome(
1723            page_len,
1724            floor.as_deref(),
1725            last_updated_at.as_deref(),
1726            budget.exhausted(),
1727        ) {
1728            PageOutcome::WindowComplete => break 'paging,
1729            PageOutcome::StopBudgetExhausted | PageOutcome::StopFloorStalled => {
1730                window_complete = false;
1731                break 'paging;
1732            }
1733            PageOutcome::Continue(next_floor) => floor = Some(next_floor),
1734        }
1735    }
1736
1737    if !window_complete {
1738        report.done = false;
1739    }
1740
1741    if let Some(cursor) = max_updated {
1742        write_cursor(runtime, project_id, "issues", &cursor).await?;
1743    }
1744    Ok(())
1745}
1746
1747#[cfg(test)]
1748mod paging_tests {
1749    use super::*;
1750
1751    #[test]
1752    fn search_query_omits_updated_qualifier_with_no_floor() {
1753        assert_eq!(search_query(None), "sort:updated-asc");
1754    }
1755
1756    #[test]
1757    fn search_query_includes_inclusive_updated_floor() {
1758        assert_eq!(
1759            search_query(Some("2024-01-01T00:00:00Z")),
1760            "sort:updated-asc updated:>=2024-01-01T00:00:00Z"
1761        );
1762    }
1763
1764    #[test]
1765    fn short_page_proves_window_complete_regardless_of_budget() {
1766        let outcome = decide_page_outcome(42, None, Some("2024-01-01T00:00:00Z"), false);
1767        assert_eq!(outcome, PageOutcome::WindowComplete);
1768        assert!(page_outcome_proves_window_complete(outcome));
1769
1770        // Even a page that runs out of budget mid-way is still a proof of
1771        // completeness if the page itself was short — the loop always
1772        // finishes sorting/processing the whole (short) page first.
1773        let outcome = decide_page_outcome(0, None, None, true);
1774        assert_eq!(outcome, PageOutcome::WindowComplete);
1775    }
1776
1777    /// This is the exact ADR-088 Amendment 1 scenario: a
1778    /// full (`PAGE_LIMIT`-sized) page came back, but the local budget was
1779    /// NOT exhausted (e.g. every record in the page already existed and
1780    /// consumed no budget) and paging is still forced to stop because the
1781    /// floor didn't move. `done` must be false here — the remote window is
1782    /// not proven exhausted just because the local budget wasn't hit.
1783    #[test]
1784    fn full_page_with_stalled_floor_is_not_window_complete_even_with_budget_left() {
1785        let outcome = decide_page_outcome(PAGE_LIMIT, Some("X"), Some("X"), false);
1786        assert_eq!(outcome, PageOutcome::StopFloorStalled);
1787        assert!(!page_outcome_proves_window_complete(outcome));
1788    }
1789
1790    #[test]
1791    fn full_page_with_advancing_floor_and_budget_left_continues() {
1792        let outcome = decide_page_outcome(PAGE_LIMIT, Some("A"), Some("B"), false);
1793        assert_eq!(outcome, PageOutcome::Continue("B".to_string()));
1794        assert!(!page_outcome_proves_window_complete(outcome));
1795    }
1796
1797    #[test]
1798    fn full_page_with_exhausted_budget_stops_without_proving_completeness() {
1799        let outcome = decide_page_outcome(PAGE_LIMIT, Some("A"), Some("B"), true);
1800        assert_eq!(outcome, PageOutcome::StopBudgetExhausted);
1801        assert!(!page_outcome_proves_window_complete(outcome));
1802    }
1803
1804    #[test]
1805    fn full_page_with_no_updated_at_stalls_rather_than_looping_forever() {
1806        let outcome = decide_page_outcome(PAGE_LIMIT, Some("A"), None, false);
1807        assert_eq!(outcome, PageOutcome::StopFloorStalled);
1808    }
1809}
1810
1811/// Issue #765: `GitLogError` classification + `recover_commit_snapshot`
1812/// retry loop (pure/synchronous). See
1813/// crates/khive-pack-git/docs/api/ingest.md#test-module-notes.
1814#[cfg(test)]
1815mod recovery_classifier_tests {
1816    use super::*;
1817
1818    fn err(phase: GitLogPhase, stderr: &str) -> GitLogError {
1819        GitLogError {
1820            phase,
1821            stderr: stderr.to_string(),
1822        }
1823    }
1824
1825    const REAL_WORLD_MESSAGE: &str = "fatal: deadbeefdeadbeefdeadbeefdeadbeefdeadbeef is in \
1826         the commit graph file, but not in the object database\nfatal: unable to parse commit: \
1827         deadbeefdeadbeefdeadbeefdeadbeefdeadbeef\nfatal: could not fetch from promisor remote";
1828
1829    #[test]
1830    fn classifies_real_world_missing_promisor_object_message_on_either_phase() {
1831        assert!(err(GitLogPhase::TouchedFiles, REAL_WORLD_MESSAGE).is_missing_promisor_object());
1832        assert!(err(GitLogPhase::Metadata, REAL_WORLD_MESSAGE).is_missing_promisor_object());
1833    }
1834
1835    #[test]
1836    fn classifies_missing_object_wording_case_insensitively() {
1837        assert!(err(
1838            GitLogPhase::TouchedFiles,
1839            "FATAL: MISSING OBJECT abc123; PROMISOR remote unavailable"
1840        )
1841        .is_missing_promisor_object());
1842    }
1843
1844    #[test]
1845    fn does_not_classify_bad_object_without_promisor() {
1846        assert!(!err(GitLogPhase::Metadata, "fatal: bad object HEAD").is_missing_promisor_object());
1847    }
1848
1849    #[test]
1850    fn does_not_classify_auth_or_network_failures() {
1851        assert!(!err(
1852            GitLogPhase::Metadata,
1853            "fatal: Authentication failed for 'https://example.com/org/repo.git/'"
1854        )
1855        .is_missing_promisor_object());
1856        assert!(!err(
1857            GitLogPhase::TouchedFiles,
1858            "fatal: unable to access 'https://example.com/org/repo.git/': Could not resolve host"
1859        )
1860        .is_missing_promisor_object());
1861    }
1862
1863    #[test]
1864    fn does_not_classify_promisor_mention_without_missing_object_wording() {
1865        // "promisor" alone (e.g. a config-dump or unrelated log line) must
1866        // not be treated as proof of corruption -- both keyword classes are
1867        // required.
1868        assert!(!err(
1869            GitLogPhase::Metadata,
1870            "fatal: promisor remote configured but unreachable"
1871        )
1872        .is_missing_promisor_object());
1873    }
1874
1875    /// Healthy repo: loads on first try, no recover call, no warning. Holds
1876    /// `cache::ENV_MUTEX` — see crates/khive-pack-git/docs/api/ingest.md#test-module-notes.
1877    #[test]
1878    fn recover_commit_snapshot_returns_no_warning_when_healthy() {
1879        let _env = crate::cache::ENV_MUTEX.blocking_lock();
1880        let dir = tempfile::tempdir().expect("tempdir");
1881        init_repo_with_commit(dir.path());
1882        let mut recover_calls = 0;
1883        let (snapshot, warning) = recover_commit_snapshot(dir.path(), None, |_repo, _err| {
1884            recover_calls += 1;
1885            Ok(None)
1886        })
1887        .expect("healthy repo loads");
1888        assert_eq!(snapshot.commits.len(), 1);
1889        assert_eq!(warning, None);
1890        assert_eq!(recover_calls, 0);
1891    }
1892
1893    /// An unclassified `git log` failure must never reach `recover` and
1894    /// must propagate as-is. Same `ENV_MUTEX` requirement as above.
1895    #[test]
1896    fn recover_commit_snapshot_never_calls_recover_for_unclassified_failures() {
1897        let _env = crate::cache::ENV_MUTEX.blocking_lock();
1898        let dir = tempfile::tempdir().expect("tempdir");
1899        // Not a git repo at all -- `git log` fails with a plain spawn/repo
1900        // error, not a classified promisor one.
1901        let mut recover_calls = 0;
1902        let result = recover_commit_snapshot(dir.path(), None, |_repo, _err| {
1903            recover_calls += 1;
1904            Ok(Some(RecoveredRepo {
1905                repo: dir.path().to_path_buf(),
1906                strategy: CacheRepairStrategy::Refetch,
1907            }))
1908        });
1909        assert!(result.is_err(), "a non-repo path must fail to load");
1910        assert_eq!(
1911            recover_calls, 0,
1912            "an unclassified failure must never invoke recover"
1913        );
1914    }
1915
1916    fn init_repo_with_commit(repo: &Path) {
1917        let run = |args: &[&str]| {
1918            let out = std::process::Command::new("git")
1919                .arg("-C")
1920                .arg(repo)
1921                .args(args)
1922                .output()
1923                .expect("spawn git");
1924            assert!(
1925                out.status.success(),
1926                "git {args:?} failed: {}",
1927                String::from_utf8_lossy(&out.stderr)
1928            );
1929        };
1930        run(&["init", "-q", "-b", "main"]);
1931        run(&["config", "user.email", "test@example.com"]);
1932        run(&["config", "user.name", "Test User"]);
1933        std::fs::write(repo.join("a.txt"), b"hello").unwrap();
1934        run(&["add", "a.txt"]);
1935        run(&["commit", "-q", "-m", "initial"]);
1936    }
1937}
1938
1939#[cfg(test)]
1940mod truncation_tests {
1941    use super::*;
1942
1943    #[test]
1944    fn under_cap_content_is_not_truncated() {
1945        let content = "a".repeat(MAX_COMMIT_EMBED_BYTES - 1);
1946        assert_eq!(truncated_embedding_head(&content), None);
1947    }
1948
1949    #[test]
1950    fn exactly_at_cap_content_is_not_truncated() {
1951        let content = "a".repeat(MAX_COMMIT_EMBED_BYTES);
1952        assert_eq!(truncated_embedding_head(&content), None);
1953    }
1954
1955    #[test]
1956    fn over_cap_content_is_truncated_to_exactly_the_cap() {
1957        let content = "a".repeat(MAX_COMMIT_EMBED_BYTES + 1);
1958        let head = truncated_embedding_head(&content).expect("over cap must truncate");
1959        assert_eq!(head.len(), MAX_COMMIT_EMBED_BYTES);
1960        assert!(content.starts_with(head));
1961    }
1962
1963    /// Multibyte scalar straddling the byte cap must roll back to a char boundary.
1964    #[test]
1965    fn multibyte_scalar_straddling_cap_rolls_back_to_char_boundary() {
1966        // Fill up to one byte short of the cap with ASCII, then place a
1967        // 3-byte character exactly across the boundary.
1968        let mut content = "a".repeat(MAX_COMMIT_EMBED_BYTES - 1);
1969        content.push('€'); // 3 bytes: straddles byte 32_768..32_771
1970        content.push_str("tail-sentinel");
1971
1972        let head = truncated_embedding_head(&content).expect("over cap must truncate");
1973        assert!(head.len() <= MAX_COMMIT_EMBED_BYTES);
1974        assert!(content.is_char_boundary(head.len()));
1975        assert!(std::str::from_utf8(head.as_bytes()).is_ok());
1976        assert!(content.starts_with(head));
1977        assert!(
1978            !head.contains("tail-sentinel"),
1979            "head must not include text past the cap"
1980        );
1981    }
1982}
1983
1984/// PR #816: `resolve_id`/`resolve_project_id` LIKE-wildcard-injection
1985/// regression tests. See crates/khive-pack-git/docs/api/ingest.md#test-module-notes.
1986#[cfg(test)]
1987mod compact_prefix_resolver_tests {
1988    use super::*;
1989    use khive_runtime::Namespace;
1990
1991    #[tokio::test]
1992    async fn resolve_project_id_rejects_like_wildcard_input() {
1993        let rt = KhiveRuntime::memory().unwrap();
1994        let token = rt.authorize(Namespace::local()).unwrap();
1995        let project = rt
1996            .create_entity(
1997                &token,
1998                "project",
1999                None,
2000                "WildcardIngestTest",
2001                None,
2002                None,
2003                vec![],
2004            )
2005            .await
2006            .unwrap();
2007        let compact = project.id.simple().to_string();
2008        let wildcard_input = format!("{}%", &compact[..8]);
2009
2010        let resolved = resolve_project_id(&rt, &wildcard_input).await.unwrap();
2011        assert_eq!(
2012            resolved, None,
2013            "a %-bearing project argument must not resolve via a wildcard LIKE scan"
2014        );
2015    }
2016
2017    #[tokio::test]
2018    async fn resolve_id_resolves_compact_prefix_over_8_chars() {
2019        let rt = KhiveRuntime::memory().unwrap();
2020        let token = rt.authorize(Namespace::local()).unwrap();
2021        let project = rt
2022            .create_entity(
2023                &token,
2024                "project",
2025                None,
2026                "CompactIngestTest",
2027                None,
2028                None,
2029                vec![],
2030            )
2031            .await
2032            .unwrap();
2033        let compact = project.id.simple().to_string();
2034
2035        let resolved = resolve_id(&rt, &token, &compact[..16]).await.unwrap();
2036        assert_eq!(resolved, Some(project.id));
2037    }
2038}
2039
2040/// PR #816: `find_document_for_path` LIKE-escaping + exact-match-ordering
2041/// regression tests. See crates/khive-pack-git/docs/api/ingest.md#test-module-notes.
2042#[cfg(test)]
2043mod find_document_for_path_tests {
2044    use super::*;
2045    use khive_runtime::Namespace;
2046
2047    async fn create_document(rt: &KhiveRuntime, token: &NamespaceToken, source_uri: &str) -> Uuid {
2048        rt.create_entity(
2049            token,
2050            "document",
2051            None,
2052            source_uri,
2053            None,
2054            Some(json!({ "source_uri": source_uri })),
2055            vec![],
2056        )
2057        .await
2058        .unwrap()
2059        .id
2060    }
2061
2062    #[tokio::test]
2063    async fn path_with_like_wildcards_resolves_only_itself() {
2064        let rt = KhiveRuntime::memory().unwrap();
2065        let token = rt.authorize(Namespace::local()).unwrap();
2066
2067        // Neither document's `source_uri` matches `path` exactly, so
2068        // resolution must fall through to the suffix-`LIKE` scan. Under the
2069        // pre-fix unescaped pattern, `%` matches zero-or-more chars and `_`
2070        // matches exactly one char, so this decoy (`100` + "" + "Q" +
2071        // `done.rs`) would incorrectly satisfy `LIKE '%src/100%_done.rs'`.
2072        // With `%`/`_` escaped, the pattern requires the literal substring
2073        // `100%_done.rs` and the decoy no longer matches.
2074        let path = "src/100%_done.rs";
2075        let decoy_source_uri = "prefix/src/100Qdone.rs";
2076        create_document(&rt, &token, decoy_source_uri).await;
2077
2078        let resolved = find_document_for_path(&rt, &token, path).await.unwrap();
2079        assert_eq!(
2080            resolved, None,
2081            "a % or _ in the path must be matched literally, not as a LIKE wildcard"
2082        );
2083    }
2084
2085    #[tokio::test]
2086    async fn exact_match_wins_over_wildcard_broadened_candidate() {
2087        let rt = KhiveRuntime::memory().unwrap();
2088        let token = rt.authorize(Namespace::local()).unwrap();
2089
2090        let path = "crates/khive-pack-git/src/ingest.rs";
2091        let broadened_suffix_path = "other/crates/khive-pack-git/src/ingest.rs";
2092        // Created first so an unordered `LIMIT 1` scan without exact-match
2093        // priority would be free to return it instead of the exact match.
2094        create_document(&rt, &token, broadened_suffix_path).await;
2095        let exact_id = create_document(&rt, &token, path).await;
2096
2097        let resolved = find_document_for_path(&rt, &token, path).await.unwrap();
2098        assert_eq!(
2099            resolved,
2100            Some(exact_id),
2101            "an exact source_uri match must always win over a suffix-LIKE candidate"
2102        );
2103    }
2104
2105    /// PR #816: TOCTOU regression — single-query snapshot must still rank
2106    /// the exact match first regardless of insertion order.
2107    #[tokio::test]
2108    async fn single_query_snapshot_prefers_exact_over_broadened() {
2109        let rt = KhiveRuntime::memory().unwrap();
2110        let token = rt.authorize(Namespace::local()).unwrap();
2111
2112        let path = "crates/khive-pack-git/src/toctou.rs";
2113        let broadened_suffix_path = "other/crates/khive-pack-git/src/toctou.rs";
2114        let exact_id = create_document(&rt, &token, path).await;
2115        create_document(&rt, &token, broadened_suffix_path).await;
2116
2117        let resolved = find_document_for_path(&rt, &token, path).await.unwrap();
2118        assert_eq!(
2119            resolved,
2120            Some(exact_id),
2121            "a single query covering both exact and broadened candidates \
2122             must still rank the exact match first, regardless of insertion order"
2123        );
2124    }
2125}