cli.sgit.ai / Research / Design 04: rename detection, history blame, grep and pickaxe (roadmap 2.4, 2.5, 2.7)

Design 04: rename detection, history blame, grep and pickaxe (roadmap 2.4, 2.5, 2.7)

Date2026-10-28 · 03:05 UTCKinddesignResearch topicsgitWritten bythe sgit-ai CLI team (Claude Code sessions), for Dinis CruzSourceteam/humans/dinis_cruz/claude-code-web/10/28/design__04__renames-blame-and-grep.md in SGit-AI__CLI at 036e33b. Published here as written; the CLI repository is where the work happened.

Read with design__00__index-and-reconciliation.md: where the two disagree (shared foundations F1–F9, the in-progress rules), the index wins.

Status: design, not built. Date: 28 Oct 2026. Roadmap: roadmap__git-parity.md items 2.4, 2.5, 2.7 (priority 2). Depends on: nothing. Feeds: design 01 (line-level merge and cherry-pick) consumes the rename-pairing API in section 5.2 and, if it does not define its own, the line differ in section 5.1. Checked against: the code at 3a1d72a and Git 2.43.0 (installed here; every Git behaviour marked "confirmed" was run in a scratch repository on 28 Oct).


1. Summary and the exact Git behaviour matched

Three read-only features, all on plaintext the client already holds, none needing a server change or a format gate:

FeatureGit behaviour matchedsgit today
Rename detectiongit diff/show/log --stat find renames by default since Git 2.9 (diff.renames=true). Three passes in diffcore-rename.c: (1) exact (same blob), (2) same basename, unique on both sides, at a raised threshold, (3) an inexact similarity matrix, skipped when sources × destinations exceeds diff.renameLimit². Threshold -M50% by default. Output R<score> (R095 b c). log --follow follows one file across renames.Exact renames only, and only in history log --stat (Vault__Inspector._file_stat_from_diff, sgit_ai/objects/Vault__Inspector.py:252), by the plaintext content_hash. history diff, history show, --files, --patch, --json and --file know no renames.
blamegit blame [<rev>] [-L …] [-w] [--porcelain | --line-porcelain] [--first-parent] -- <file>: each line of the file at <rev> attributed to the commit that last changed it, following renames, through merges, boundary commits marked ^.Nothing.
grepgit grep [-i -w -v -n -c -l -L -F -E -e … -o -q -A/-B/-C] <pattern> [<tree>…] [-- <pathspec>…]: the tracked working-tree files by default, a tree when given (HEAD~1:path:line:text), exit 0 on a match and 1 on none.Nothing (only history log --grep on messages).
Pickaxegit log -S <string>: commits where the number of occurrences of the string in a file changed. git log -G <regex>: commits whose patch adds or removes a line matching the regex. Merge commits are not searched unless -m.Nothing.

Git behaviours confirmed in Git 2.43 for this document:

BehaviourResult
Rename threshold 50%A 100-line file renamed and cut to 49 lines shows D a + A b; 49 → 51 lines shows R095 b c.
diff.renameLimit default 10001,001 deleted × 1,001 added files with different basenames, each edited: warning: exhaustive rename detection was skipped due to too many files, all A/D. With -c diff.renameLimit=1001: all R.
Basename pass bypasses the limitThe same 1,001 files moved to another folder with the same basenames, each edited: all R, no warning.
Exact pass bypasses the limit1,001 files moved unchanged: all R100.
A lone empty file movedR100 e1 e2.
-S vs -G-S five lists the commits that changed the count of five in a file; -G five the commits whose added or removed lines contain five. A moved line matches -G, not -S.
grep exit code1 when nothing matches.
grep on a treegit grep -n five HEAD~1 prints HEAD~1:c.txt:5:five.
blame default line^5349944 a.txt (A 2026-10-28 02:56:20 +0000 4) 4: ^ for a boundary commit; the path column appears when any line's origin path differs from the final path.
blame --porcelain<sha> <orig line> <final line> <group size>, then on a commit's first appearance author, author-mail, author-time, author-tz, committer…, summary, previous <sha> <path>, filename <path>, then the line after a TAB.
blame -L 3,+2 and -L /five/,+1Ranges as documented.

Not confirmed here (no man pages in this container): merge.renameLimit default 7000. Design 01 should confirm it if it adopts that value.

2. Scope and non-goals

In scope

Not in scope

3. CLI surface

Rule 9 (tests/unit/cli/test_CLI__B07__Namespace_Moves.py:104): blame and pickaxe are about commits, so they go under history. grep is a daily verb over files. It goes in the file namespace beside cat/ls/write, and also at the top level as sgit grep, the same way cat, ls and write are both (sgit_ai/cli/CLI__Main.py:472-495, sgit_ai/plugins/file/CLI__File.py). That adds 'grep' to TOP_LEVEL_COMMANDS, as a deliberate edit (D1).

3.1 Rename options (on history diff, history show, history log)

OptionMeaningDefault
-M[<n>], --find-renames[=<n>]Rename threshold, percent (-M90, -M90%, or -M0.9 as in Git)50
--no-renamesExact pairing off too; adds and deletes as todayoff (renames are on)
-l <num> / --rename-limit <num>Sources × destinations above <num>² skips the inexact pass (exact and basename passes still run)1000
--follow (with history log --file)Continue across renamesoff

Human output:

diff --git a/docs/old.md b/docs/new.md
similarity index 95%
rename from docs/old.md
rename to docs/new.md
--- a/docs/old.md
+++ b/docs/new.md
@@ …

With --files-only: R095 docs/old.md -> docs/new.md.

JSON (additive, existing fields keep their meaning; see D4):

Schema (file)Field addedShape
Schema__History_Log_Commit_Entry (history_log_v1)files_renamed[{old_path, new_path, score, exact}]; a renamed file is no longer listed in files_added / files_deleted
Schema__History_Diff_Result (history_diff_v1)files_renamed[{old_path, new_path, score, exact, lines_added, lines_removed}]
Schema__Diff_Fileold_path, similaritystatus='renamed'; similarity 0–100

3.2 sgit history blame

sgit history blame [<rev>] [-L <range>]... [-w] [--first-parent] [-s] [-e] [--abbrev N]
                   [--porcelain | --line-porcelain | --json] [--text] [--offline]
                   [--vault-key KEY] [-d DIR] [--] <path>

<range> forms (as Git, confirmed): N,M, N,+K, N,-K, N (to the end), ,M, /regex/ (the first matching line, to the end), /regex/,+K. Several -L options are merged and sorted.

Default output, one line per line of the file:

^3f9a2c1d0b7e docs/a.md (alice    2026-10-27 14:30:02 +0000  4) text
57567877efb2 docs/c.md (bob-agent 2026-10-28 02:56:21 +0000  5) five
ColumnRule
CommitThe hex part of the id, --abbrev characters (default 12, the full format-1 width). ^ prefix for a boundary commit (the root, or a shallow boundary).
PathShown when any line's origin path differs from the final path, as in Git.
AuthorThe branch name from the branch index; else the first 12 characters of author_key_id; -e shows the key id always (D8).
DateThe commit's timestamp_ms in UTC, +0000. sgit records no time zone.
LineThe final line number, right-aligned to the width of the largest.
-sDrops author and date.

--porcelain and --line-porcelain: Git's exact layout, so tools that parse Git's output work unchanged.

<40-or-64-char id field> <orig line> <final line> [<group size>]
author <branch name or key id>
author-mail <key id or "unsigned">
author-time <seconds>
author-tz +0000
committer <same as author>
committer-mail <same>
committer-time <same>
committer-tz +0000
summary <first line of the message>
boundary                       (only for a boundary commit)
previous <parent id> <path>    (when the commit has a parent holding the file)
filename <origin path>
\t<line>

The id field is the full obj-cas-imm-<hex> id, never padded to 40.

--json (history_blame_v1):

{"schema": "history_blame_v1", "path": "docs/c.md", "rev": "<commit id>", "boundary_reason": "",
 "commits": {"<id>": {"timestamp_ms": 0, "author": "bob-agent", "author_key_id": "…", "summary": "…",
                      "boundary": false, "previous_id": "…", "previous_path": "…"}},
 "lines": [{"final_line": 5, "orig_line": 5, "commit_id": "<id>", "orig_path": "docs/a.md", "text": "five"}],
 "stats": {"commits_visited": 0, "blobs_read": 0, "blobs_fetched": 0, "index_plan_used": true}}

boundary_reason:

Exit codes:

CodeWhen
0Success
1The path is not a file at <rev>; or the file is binary without --text; or an -L range is out of bounds or matches nothing (Git's message: fatal: file has only N lines); or the path is outside a scoped clone; or the regex guard refused the pattern

Notes, written to stderr after the output:

3.3 sgit grep (also sgit file grep)

sgit grep [-i] [-w] [-v] [-F | -E | -P] [-n] [-c] [-l | -L] [-o] [-q] [-h | -H]
          [-A N] [-B N] [-C N] [-m N] [--untracked] [--text | -I] [--json] [--offline]
          [--vault-key KEY] [-d DIR]
          (<pattern> | -e <pattern>...) [<rev>...] [-- <pathspec>...]

Pattern and dialect

What is searched

Output

Pathspecs: after --, Git's basic form: a literal prefix (docs/, docs/a.md) or a glob with *, ? and […] (fnmatch on the full path, ** matching folders). Magic forms (:(exclude), :!) are not supported and are refused by name.

--json (grep_v1):

{"schema": "grep_v1", "rev": "", "pattern": ["five"],
 "matches": [{"rev": "", "path": "c.txt", "line": 5, "text": "five", "spans": [[0, 4]]}],
 "files_searched": 0, "binary_matches": ["img.png"],
 "skipped": {"not_checked_out": 0, "not_local": 0, "outside_scope": 0}}

Exit codes (D3):

CodeWhen
0At least one match (with -L: at least one file printed; with -q: a match, and nothing printed)
1No match
2Error: bad pattern, guard refusal, unknown revision, pathspec magic, outside scope

3.4 Pickaxe, on sgit history log

OptionMeaning
-S <string>Commits where the count of <string> changed in some file (against the commit's only parent)
--pickaxe-regex-S takes a regex instead of a literal
-G <regex>Commits whose diff adds or removes a line matching <regex>
--pickaxe-allWith --stat/--files, list every changed file of a matching commit, not only the matching ones (Git's flag)
-i / --regexp-ignore-caseCase-insensitive -S/-G (Git's flag)

4. How the code works today

4.1 Objects and what can be compared without decrypting

FactWhereConsequence
A blob is encrypt(read_key, plaintext) with a random IVVault__Crypto.encrypt, sgit_ai/crypto/Vault__Crypto.py:300; deterministic encryption is for trees and tree-entry metadata only (:263)The same content written twice gets two blob ids. Blob-id equality means the same content; inequality says nothing.
A commit reuses the old blob id when a path's content is unchangedVault__Sub_Tree.encrypt_or_reuse_blob, sgit_ai/storage/Vault__Sub_Tree.py:517 (compares the old entry's content_hash at the same path)A renamed file always gets a new blob id: renames cannot be found by blob id.
Tree entries carry content_hash_enc, size_enc, content_type_encSchema__Object_Tree_Entry, sgit_ai/schemas/Schema__Object_Tree_Entry.pycontent_hash = the first 12 hex of sha256(plaintext) (Vault__Crypto.content_hash, :335), 48 bits. It is the committer's claim: nothing re-hashes it on read.
Flat maps: {path: {blob_id, size, content_hash, content_type, large}}Vault__Sub_Tree.flatten (:133), process cache FLAT_CACHE and disk cache .sg_vault/local/flat-cache/<tree id>.json (0600, newest few kept, :413)Exact renames can be found from two flat maps or a pruned diff with no blob read. The disk cache holds paths, sizes and content hashes in clear: TM-R41, accepted. No file content is ever cached on disk.
Pruned tree diffVault__Sub_Tree.diff_trees (:259): opens only folders whose ids differ, returns added, deleted, modified entriesThe cost follows the change. It is the base for every per-commit diff here.
Path lookupVault__Sub_Tree.resolve_path_target (:437) loads trees through Type_Safe (_load_tree, ~3 ms a tree)Too slow to call per commit. Section 5.4 uses _load_tree_entries (plain JSON, :326).

4.2 Exact renames today

Vault__Inspector._file_stat_from_diff (sgit_ai/objects/Vault__Inspector.py:252-276) pairs each added path with the first unused deleted path of the same content_hash (falling back to blob_id). Ties go in sorted path order, not Git's basename preference. It is used only by history log --stat through inspect_commit_chain (:97), whose per-commit diff comes from three sources, in order:

  1. the pack index's changes member (_diff_from_provider, :217);
  2. two cached flat maps (_diff_from_flats, :187);
  3. diff_trees.

A D record from the index carries the old content_hash (Vault__Pack_Index.changes_of, sgit_ai/core/actions/pack/Vault__Pack_Index.py:201), so exact pairing works on the index path too.

4.3 history diff / show / log --files/--patch/--json decrypt everything

Vault__Diff (sgit_ai/core/actions/diff/Vault__Diff.py):

MethodWhat it does
diff_commits (:66)Reads both commits' files through _read_commit_files → _flatten_commit (:636), which loads and decrypts every blob of the tree
show_commit (:78)The same for the commit and its parent
log_range_with_details (:290)Calls show_commit per commit, so history log --patch decrypts the whole vault twice per commit
diff_files (:453)Then compares {path: bytes} maps by sha256 of the bytes and runs difflib.unified_diff (:540)

On the 228,000-file vault that is about 1.2 GB decrypted per side, per commit. diff_vs_head / diff_vs_remote / diff_vs_commit (:35-58) also read every working-copy file and every committed blob.

log_file (:125) walks the first-parent chain and, for each commit and its parent, calls flatten on the whole tree to read one path's blob id. It compares blob ids only, so a writer that re-encrypts unchanged content (the web UI) shows as modified.

On-demand fetch: CLI__Diff._try_fetch_commits (sgit_ai/cli/CLI__Diff.py:35) → Vault__Diff.ensure_commit_local (:736) → Vault__Sync__Pull._fetch_missing_objects(..., include_blobs=True). It is gated on a configured remote (_on_demand_api, :15: a never-pushed vault never reaches the default host). One blob is fetched in Vault__Sync__Sparse.sparse_cat (sgit_ai/core/actions/sparse/Vault__Sync__Sparse.py:177): api.read or read_large, then _save_verified.

4.4 history log dispatch and filters

4.5 Pack indexes (Phase 1 reader)

MemberWrittenRead today
changes (commit index): [{path, op A/M/D, old_blob, new_blob, size, content_hash, content_type, large}] against the first parentbuild_commit_members (Vault__Pack_Index.py:272)changes_of_commit (Vault__Pack_Index__Reader.py:245) for history log counts and stat, and pull plans
file-commits:<xx> (snapshot index): {path: [commit ids that changed it, newest generation first]}, sharded by sha256(path)[:2]build_snapshot_members (:340-352), _file_commits (:356)Nobody
blob-paths:<xx>: {blob_id: [paths at the head]}:340Nobody
commit-gen, commits:<n>:330-338Generation numbers via generation_of

TM-R42: a snapshot index is anchored to the root tree only, and is a plan, never a proof.

4.6 Partial clones

5. Design

5.1 Line differ: Vault__Line_Diff

One line differ for blame, -G, the unified diff and the merge.

Interface

Algorithm: Myers O((N+M)·D) greedy diff, the algorithm behind Git's default

Why not difflib alone

5.2 Renames: Vault__Renames

Classes

Entry point (also the API design 01 consumes)

def detect(self, deleted: dict, added: dict, content_of=None) -> Schema__Rename_Result:
    """deleted/added: {path: flat entry} (the diff_trees / flatten shape: blob_id, size, content_hash, ...).
    content_of: callable(entry) -> bytes | None, the plaintext of an entry (None = not available).
    Without content_of only the exact pass runs. Never raises for a missing blob."""

Pass 1, exact (always, no limit):

  1. Group deleted by (content_hash, size), skipping entries without a content_hash.
  2. For each added path in sorted order, the candidates are the deleted paths with the same key not yet used. Pick the one with the same basename if any (Git's preference), else the first by path.
  3. Score 100, exact=True.
  4. A source pairs at most once. A second destination of the same content is an add (copies are out of scope).
  5. Empty files take part (Git 2.43 pairs a lone moved empty file; confirmed). With several empty files, only same-basename pairs are made, so empty files never pair at random. Test 9.1 R10 checks this against Git.

Pass 2, basename (needs content_of, no limit):

  1. Among unpaired paths, take basenames that occur exactly once among the remaining sources and exactly once among the remaining destinations.
  2. Score each such pair by similarity.
  3. Accept at min_basename_score = min_score + (100 - min_score) / 2 (75 at the default), Git's rule from 2.33.

Pass 3, inexact matrix (needs content_of):

  1. If len(src) > limit or len(dst) > limit and len(src) * len(dst) > limit * limit, skip the pass and set limit_hit=True (Git's too_many_rename_candidates).
  2. Else, for each destination, score every source that passes the size precheck, keep the best four (NUM_CANDIDATE_PER_DST, as Git), sort all kept pairs by score descending then by destination path, and accept greedily, each path once, at score >= min_score.

similarity(src: bytes, dst: bytes) -> int follows Git's diffcore-delta.c and estimate_similarity.

  1. Size precheck: with max_size = max(len) and delta = abs(len(src) - len(dst)), return 0 when max_size * (100 - min_score) < delta * 100. A file cannot be a 50% rename of one under half its size.
  2. Chunking (hash_chars): chunk each buffer. A chunk ends after a \n or after 64 bytes. For a text buffer (no NUL in the first 8,000 bytes), a \r directly before \n is skipped. Key each chunk by its bytes; the count is the chunk's length.
  3. Count: copied = Σ min(src_count[k], dst_count[k]) over keys present in both.
  4. Score: copied * 100 // max_size, truncated as Git prints it (Git computes on MAX_SCORE = 60000 and prints score * 100 / MAX_SCORE; the integer percent is the same up to rounding). Test 9.1 R4 compares with Git within ±1.
  5. Empty src or dst: 0 (an empty file is only ever an exact rename).

content_of cost control

Trust:

Coordination note for design 01

5.3 Reading content: Vault__Blob_Reader

Purpose: one place that turns a flat entry into plaintext, for renames, blame, grep, pickaxe and the pruned diff.

Interface

Behaviour

  1. A local object → obj_store.load (this also reads from a --pack-store clone by offset).
  2. Else, with api and not offline → fetch with api.read / read_large, _save_verified (verified by content address before it is written, as sparse_cat does), then load.
  3. Else → None and blobs_missing += 1.

5.4 Path history: Vault__Path_History

Interface

Methods

Consumers

5.5 The pruned diff (prerequisite)

Vault__Diff gains _changed_files(tree_new, tree_old, renames: Vault__Renames) -> list[Schema__Diff_File]:

  1. Take diff_trees (or the index's changes when the caller has them).
  2. Run Vault__Renames.detect over added/deleted.
  3. For each modified or renamed file, read both sides with Vault__Blob_Reader and run the unified diff. For an added or deleted file read one side (needed for --patch of an added file and for the binary flag).

The diff methods change as follows:

MethodChange
diff_commits, show_commit, log_range_with_details, diff_rangeSwitch to _changed_files. They no longer call _flatten_commit / diff_files for two commits.
diff_vs_head, diff_vs_remote, diff_vs_commitScan the working copy through the scan cache (Vault__Sync__Base scan: the size/mtime/hash rule) to find changed paths. Read and decrypt only those, plus the committed side. Unchanged files are no longer read.

Output stays the same except that renames now appear (D4). Schema__Diff_Result.files loses its unchanged rows for commit-to-commit diffs. They were never printed (CLI__Diff._print_result skips them, sgit_ai/cli/CLI__Diff.py:271), and no JSON path emits Schema__Diff_Result for two commits (--json there goes through diff_range). The working-copy JSON path keeps them only if a test shows a consumer, else drops them too; step 3 checks this.

5.6 Blame: Vault__Blame

Classes

Entry point: blame(directory, path, rev='', ranges=None) -> Schema__Blame_Result.

Algorithm (Git's blame.c, assign_blame / pass_blame, simplified to what we support):

  1. Start.
  1. Entries.
  1. Process the next origin O (commit C, path P, entry E):
  1. Finish. Final entries are sorted by final_start and expanded to lines with orig_line = suspect_start + k. Commit metadata is collected:

Pack index acceleration (step 9).

  1. When the head's snapshot index is local and verified (open_index(kind='snapshot')), read file-commits:<shard_of_path(P)> for P.
  2. The listed commits are the plan: the walk visits only them, and treats each unlisted stretch between two listed commits as unchanged for P.
  3. Because the snapshot index is anchored only at the root (TM-R42), the plan is checked as it is used. For each step from listed commit C to the next listed ancestor C' on the walk, same_at(parent(C).tree, C'.tree, P) must hold: the file must really be unchanged across the skipped stretch.
  4. One failed check drops the plan for the rest of the command (noted once on stderr: index plan for <path> did not match the trees; walking) and the walk continues commit by commit.
  5. A lying index can therefore cost time, never a wrong attribution.
  6. Renames end the plan at the commit where the path was added. The plan of the old path is read from its own shard.

Partial clones and limits:

5.7 Grep: Vault__Grep

Classes

Engine

  1. Compile. One Python re pattern: -F escapes each pattern; several patterns join as (?:p1)|(?:p2); -w wraps as (?<![\w])(?:…)(?![\w]) (Git's word rule: the match must start and end at a word boundary of [A-Za-z0-9_]; test 9.3 G6 checks edge cases against Git); -i adds re.IGNORECASE.
  1. Prefilter per file. One search over the whole decoded text; no match → skip the file without splitting lines. With -v, -L or -c, the prefilter is skipped (they need every line). That keeps the common case at C speed: one regex scan per file.
  2. Lines. On a hit, iterate lines with offsets (str.splitlines(keepends=True) once), collect matches, context windows and -m cut-off, and emit Schema__Grep_Match rows in path order then line order.
  3. Binary. NUL in the first 8,192 bytes:

Sources

ModeFilesHow read
Working copy (no <rev>)Tracked files present on disk: the head's flat map ∩ disk, filtered by Vault__Ignore as status filters, --untracked adds the rest; pathspec applied firstVault__Path_Guard.read_regular (never through a link, never blocks on a FIFO), the same reader _scan_working_files uses (Vault__Diff.py:814). A tracked file not on disk (selective working copy) is not searched and is counted in skipped.not_checked_out, with a note: N tracked file(s) not checked out were not searched; search them with: sgit grep <pattern> HEAD (D7).
<rev> (one or more)flatten(tree) (scoped: Vault__Scoped_Tree.flatten, opaque folders counted in skipped.outside_scope) ∩ pathspecVault__Blob_Reader.prefetch then content; missing and not fetchable → skipped.not_local with a note naming the count and sgit fetch

Order and streaming.

5.8 Pickaxe: Vault__Pickaxe

Interface

Rules

5.9 Shared pieces and safe types

NewFileWhat
Vault__Regex_Guardsgit_ai/core/Vault__Regex_Guard.pycheck(pattern) -> None (raises Vault__Revision_Error, the class --grep already raises) and nested_repeat(pattern), moved from Vault__Log_Filter (:50-138), which then delegates (its tests keep passing unchanged). Adds a pattern length cap of 1,000 characters.
Safe_Str__Content_Pathsgit_ai/safe_types/Safe_Str__Content_Path.pyA vault content path for output: regex ^[^\x00-\x1f\x7f]*$ in MATCH mode (any printable Unicode, no control characters), max 4,096, trim_whitespace=False, strict. A control character in a decrypted name is a refusal, never a rewrite.
Safe_Str__Grep_Patternsgit_ai/safe_types/Safe_Str__Grep_Pattern.pyAny printable text up to 1,000 characters, no NUL, trim_whitespace=False.
Safe_UInt__Rename_Scoresgit_ai/safe_types/Safe_UInt__Rename_Score.py0..100.
Safe_Str__Blame_Rangesgit_ai/safe_types/Safe_Str__Blame_Range.py^[0-9,+\-/]…$-style spec text up to 200 characters; parsing in Vault__Blame.parse_range.

5.10 File-by-file changes

FileChange
sgit_ai/core/actions/diff/Vault__Line_Diff.pyNew (5.1)
sgit_ai/core/actions/diff/Vault__Renames.pyNew (5.2)
sgit_ai/core/actions/history/Vault__Blob_Reader.pyNew (5.3)
sgit_ai/core/actions/history/Vault__Path_History.pyNew (5.4)
sgit_ai/core/actions/history/Vault__Blame.pyNew (5.6)
sgit_ai/core/actions/history/Vault__Grep.pyNew (5.7)
sgit_ai/core/actions/history/Vault__Pickaxe.pyNew (5.8)
sgit_ai/core/Vault__Regex_Guard.pyNew; Vault__Log_Filter delegates (5.9)
sgit_ai/schemas/diff/Schema__Rename_Pair.py, Schema__Rename_Result.pyNew
sgit_ai/schemas/history/Schema__Blame_Line.py, Schema__Blame_Commit.py, Schema__Blame_Result.pyNew
sgit_ai/schemas/history/Schema__Grep_Match.py, Schema__Grep_Result.pyNew
sgit_ai/schemas/history/Schema__History_Rename.pyNew: old_path, new_path, score, exact, lines_added, lines_removed
sgit_ai/schemas/Schema__Diff_File.pyAdd old_path : Safe_Str__Content_Path = None, similarity : Safe_UInt__Rename_Score; status may be 'renamed'
sgit_ai/schemas/history/Schema__History_Log_Commit_Entry.pyAdd files_renamed : list[Schema__History_Rename]
sgit_ai/schemas/history/Schema__History_Diff_Result.pyAdd files_renamed : list[Schema__History_Rename]
sgit_ai/core/actions/diff/Vault__Diff.pyPruned diff (5.5); log_file on Vault__Path_History with follow (5.4); rename headers in _unified_diff; opcodes() from Vault__Line_Diff
sgit_ai/objects/Vault__Inspector.py_file_stat_from_diff delegates to Vault__Renames (exact only when no reader is given, so plain log --stat stays tree-only and fast; inexact with a reader); stat tuples gain the score
sgit_ai/cli/CLI__Blame.pyNew CLI__Blame(Type_Safe): cmd_blame(args), the three output formats, the exit codes
sgit_ai/cli/CLI__Grep.pyNew CLI__Grep(Type_Safe): register_args(parser) (shared by top-level and file grep), cmd_grep(args), exit codes 0/1/2
sgit_ai/plugins/history/CLI__History.pyblame subparser; -M/--find-renames, --no-renames, --rename-limit on log/diff/show; --follow, -S, -G, --pickaxe-regex, --pickaxe-all, -i on log; dispatch: pickaxe allowed with the plain-log filters and --file
sgit_ai/plugins/file/CLI__File.pyfile grep subparser via CLI__Grep.register_args
sgit_ai/cli/CLI__Main.pyTop-level grep parser, injection of CLI__Blame / CLI__Grep
sgit_ai/cli/CLI__Diff.pyPass rename options; print rename headers and R rows; cmd_log_file prints R rows with --follow
tests/unit/cli/test_CLI__B07__Namespace_Moves.py'grep' added to TOP_LEVEL_COMMANDS

Compatibility

6. Security and threat model

TopicAnalysisDecision
Regex denial of servicePatterns come from the user (or an agent driving sgit). Content comes from teammates (A3) and is verified by content address, so the host cannot pick it. Python's re has no timeout. A vulnerable pattern over hostile content is the risk.Every user pattern (grep, -G, --pickaxe-regex, blame -L /re/) goes through Vault__Regex_Guard.check: nested repetition refused (the rule --grep already uses), length ≤ 1,000. -F and -S without --pickaxe-regex are literal and never compiled as a regex.
Plaintext on diskBlame, grep, pickaxe and renames decrypt into memory only. The Vault__Blob_Reader memo is in-process and bounded. No new on-disk cache. The flat cache (paths, sizes, content hashes, types; TM-R41) is read, not extended. Fetched blobs are written to bare/data encrypted, as history show's on-demand fetch already does.A future blame or grep cache would be plaintext-derived and must live under .sg_vault/local/ at 0600 and be added to TM-R41. Not planned.
Lying content_hashA writer (A3) can claim any content_hash. Display: a false R row, nothing more. Merge: design 01 verifies exact pairs by bytes (verify_exact=True).Accepted for display; closed for the merge.
Lying pack index planfile-commits is anchored only at the root (TM-R42). Blame verifies every skipped stretch with same_at (5.6): a lie costs a fallback walk, never a wrong line.TM-R42's row gains one sentence: "file-commits is used by history blame and log --follow as a skip plan, verified per stretch".
Terminal escapes in contentgrep, blame, cat and history diff print decrypted file text raw, as Git does. A teammate's file holding ANSI or OSC sequences can rewrite what a terminal shows (A3).New row TM-R4x (accepted): "decrypted file content is printed raw by cat, history diff/show, grep and blame, as Git prints it; --json output escapes it". Known_Gaps test: a file containing \x1b]0;x\x07 is printed byte for byte in plain mode and escaped in --json.
Control characters in decrypted namesA name with \n or \x1b could forge output lines. Safe_Str__Content_Path refuses control characters, so a JSON row cannot hold one. Plain output prints such a path quoted the way Git quotes it ("a\nb" with C escapes, core.quotePath).Vault__Grep / Vault__Blame call one helper, Vault__Path_Quote.quote(path) (a method on a small new class in sgit_ai/core/Vault__Path_Quote.py), for every printed path.
Working-copy readsGrep reads only regular files through Vault__Path_Guard.read_regular: no symlink followed (TM-F11, F27) and no FIFO blocking (F1). Ignored folders (.sg_vault, .git) are never entered.The same reader as status.
On-demand fetchOnly with a configured remote (_on_demand_api). Fetched objects are verified before they are written. --offline forbids it.Matches history show today.
Information to the hostA fetch on demand tells the host which blob ids a clone asked for, as show, cat and fetch already do (TM-R08 family). Grep in the working copy contacts nobody.Noted in the user doc for --offline.

7. Interactions

Clone kindRenamesBlameGrepPickaxe
FullAll passesFull walk, or the index planWorking copy or revDAG walk
Shallow (clone --depth)Normal for commits that are localStops at a boundary: lines blamed there are marked ^, with a stderr note and boundary_reason: 'shallow'Rev mode works for any local commit; a rev below the boundary → exit 2: <rev> is below this clone's shallow boundary; deepen with: sgit fetch --unshallowSearches local history; the output ends with … history stops here: shallow clone, as history log does today (CLI__Vault.py:2306)
Scoped (clone --path)Pairs within held folders only. Opaque folders carry no content, so a rename across the scope edge shows as A or D.A path outside the scope → exit 1 with the widen hintRev mode searches held folders only, counted in skipped.outside_scope with a noteChanges outside the scope are invisible (their trees are not local); note: searched inside <folders> only
Sparse / selectiveBlobs fetched on demand for inexact scoring; offline, exact pass only (unread > 0 → note)Blobs fetched on demand (prefetch); offline → offline boundaryWorking copy: on-disk files only + note (D7). Rev: fetch on demand, else skipped.not_localFetch on demand per commit; offline → that commit's unreadable files are counted, and the note gives the count
Pack vaultchanges member gives the per-commit lists; blobs from the local storefile-commits plan (5.6)As fullchanges member per commit (no tree read)
--pack-storeObjects read by offset from kept packs, verified on readSameSameSame
Read-only cloneSame as full (reading only)SameSameSame
--vault-key on a bare checkout (history log already allows it, CLI__Vault._read_key_or_exit, :2247)SupportedSupported (rev required, since no clone branch head → history blame HEAD is resolved from the named branch)Rev mode only (no working copy)Supported

8. Performance, caps and limits

Reference vaults: the 76,000-file / 58,553-folder synthetic vault (release readiness, 26 Oct) and the 228,000-file / 38,000-leaf-folder one (0.25.0 notes). Estimates below are from the measured costs: ~3 ms a tree through Type_Safe (avoided here), well under 1 ms through _load_tree_entries, and AES-GCM decrypt in C.

OperationTodayPlannedBound
history show of a 5-file commit, 228k vaultDecrypts both trees' every blob (~2.4 GB)diff_trees (folders on 5 paths) + 10 blob reads: under 1 sFollows the change
history log --patch -n 10, 228k vault10 × two full decrypts10 × the line aboveFollows the change
history log --stat -n 50 with renamesExact pairing, tree-onlyThe same by default (exact only without a reader); inexact only for commits with both adds and deletes, reading only those blobsRENAME_MAX_BYTES 4 MB, limit 1000
history log --file P over 2,000 commits4,000 full flattens (incremental from the flat cache, but a dict copy of 228k entries each)2,000 × entry_at (depth ≈ 5 tree reads, memoised; most short-circuit at the root) ≈ 1–3 sTree memo 4,096
history blame of a 1,000-line file, 2,000-commit history, 40 commits touched itn/aWalk: 2,000 × same_at (root-equal fast path) + 40 diffs ≈ 2–4 s; with the index plan: 40 steps ≈ 0.5 sMemo 64 MB
sgit grep x (working copy, 228k files, ~1.2 GB)n/aOne read + one C-level search per file. Disk-bound: ~10–30 s on a fast disk. Prints as it goes.No cap; progress on stderr after 5 s
sgit grep x HEAD (228k blobs)n/aDecrypt 228k objects (Python overhead per object dominates): ~60–120 s. A pathspec is the intended use.A warning when more than 20,000 files are searched with no pathspec
history log -S foo -n 20n/aPer commit: the changed blobs (old + new). On the newsroom vault (~300 files per pass): ~1–2 s per pass commit.Stops at -n

Hard caps:

9. Test plan

All tests are real objects. Vaults are built with Vault__Test_Env (tests/_helpers/vault_test_env.py: fixed test key, in-memory API) and commits made with write_file. Where Git is the oracle, the same history is built in a temporary Git repository (git -c user.name=t -c user.email=t@t). Those tests are skipped when shutil.which('git') is None. CI runners have Git. No __init__.py in test folders.

9.1 tests/unit/core/actions/diff/test_Vault__Renames.py — Test_Vault__Renames

#CaseExpect
R1A file moved unchangedOne pair, score 100, exact=True; no blob read (reader.blobs_read == 0)
R2Two identical files deleted, one added with the basename of the secondPaired with the same-basename source
R31,001 unchanged movesAll exact pairs, limit_hit=False
R440 generated edits (insert, delete, change lines; CRLF; long lines over 64 bytes; no final newline)Score equals git diff -M --name-status R-score ±1 for every pair, and the same R/A/D verdict
R5100 lines → 49 linesNot paired (A + D), as Git
R649 → 51 linesPaired, 95 (Git: R095)
R71,001 × 1,001 edited, different basenameslimit_hit=True, all A/D; with limit=1001, all pairs
R8The same with identical basenames in another folderAll paired through the basename pass, no limit_hit
R9A binary file renamed with one byte changedPaired by similarity (Git pairs binaries)
R10One empty file moved; then three empty files moved, one with the same basenameOne pair; then only the same-basename pair (oracle: Git)
R11Size precheck: 10 KB → 3 KBNot scored (similarity returns 0 without chunking)
R12content_of returns None for one sideExact pass only for it; unread counted
R13verify_exact=True with a forged content_hash (two different contents, same claimed hash)Pair rejected by the byte check
R14DeterminismThe same input in shuffled dict order gives identical output

9.2 tests/unit/core/actions/diff/test_Vault__Line_Diff.py — Test_Vault__Line_Diff

#Case
L1Identical, empty, one empty side
L2matching_blocks contract: ascending, the sentinel, total equal lines = LCS length for 200 random pairs (LCS by a reference dynamic-programming method in the test)
L3A file of 500 blank lines plus edits: no line lost to junk heuristics
L4-w ignores spaces and tabs inside lines
L5No final newline is a distinct line
L6The cost bound: a 20,000 × 20,000 random-line pair finishes under 2 s

9.3 Other unit tests

tests/unit/core/actions/history/test_Vault__Path_History.py

tests/unit/core/actions/history/test_Vault__Blame.py — Test_Vault__Blame. Oracle: git blame --line-porcelain mapped through unique commit messages.

#Case
B1Linear history: append, insert at the top, delete, change; every line's commit equals Git's
B2Rename then edit: lines before the rename are blamed on the original commit with orig_path = the old path
B3A merge: lines from both parents attributed as Git; --first-parent as git blame --first-parent
B4A whole-file pass across 50 untouched commits: stats.commits_visited shows no diff for them
B5-L 3,5, -L 3,+2, -L 5,-2, -L /five/,+1, two -L, an out-of-range -L (exit 1, Git's message)
B6-w: a whitespace-only change does not take the blame
B7Binary refused; --text blames it
B8Shallow clone (clone --depth 2): older lines on the boundary commit, ^, boundary_reason='shallow', note printed
B9Scoped clone: path outside → exit 1 with the widen hint; inside → normal
B10Selective clone (--no-checkout), remote configured: blobs fetched (blobs_fetched > 0), same result as full; --offline: boundary_reason='offline'
B11Pack vault with a snapshot index: the plan used (index_plan_used), fewer commits visited, same lines
B12Lying plan: a snapshot index rebuilt with file-commits omitting one commit that changed the file → the stretch check fails, the walk falls back, lines equal to B11's, note printed
B13Porcelain output: parsed by a small parser written to Git's documented grammar; every field present; boundary lines for ^ commits
B14JSON round-trip: Schema__Blame_Result.from_json(r.json()).json() == r.json() (the CLAUDE.md invariant)
B15A non-ASCII path (docs/josé.md) printed unmangled in all three formats

tests/unit/core/actions/history/test_Vault__Grep.py — Test_Vault__Grep. Oracle: git grep on the same tree, committed and checked out.

#Case
G1Each flag alone: -n -c -l -L -i -w -v -F -o -h -H -m 1, two -e; the output lines equal Git's (working copy and HEAD~1 modes)
G2Context -A 1 -B 2 -C 1 including overlapping windows and the -- separator
G3Exit codes 0/1/2, and -q
G4Pathspec: prefix, glob, **; magic :(exclude) refused (exit 2)
G5Binary: Binary file x matches; -I; --text
G6-w edges: foo_bar, foo-bar, start and end of line; against Git
G7Untracked: not searched by default; --untracked searches it; ignored files never
G8Symlink in the working copy is not followed; a FIFO does not block (as the scan tests do)
G9Selective clone: the not-checked-out count and note; HEAD mode searches them with fetch on demand
G10Scoped clone in rev mode: outside_scope counted
G11Guard: (a+)+$ refused (exit 2) before any file is read
G12Non-UTF-8 file: ASCII parts match; output bytes round-trip
G13Streaming: the first match is printed before the last file is read (a 2,000-file tree with the match in the first file; the reader counter at first output is < 2,000)
G14JSON schema round-trip
G15A path holding a newline: quoted as Git quotes it

tests/unit/core/actions/history/test_Vault__Pickaxe.py — Test_Vault__Pickaxe. Oracle: git log -S/-G --format=%s.

#Case
P1-S count change vs a moved line (-S misses, -G matches), against Git
P2Added and deleted files
P3--pickaxe-regex
P4-i
P5Merges skipped, and the note
P6Combined with --grep, --author, --since, --file
P7-n 2 stops early (stats shows fewer commits read than the history)
P8Pack vault uses the changes member (no diff_trees call: counted on Vault__Path_History)
P9Shallow note

tests/unit/core/actions/diff/test_Vault__Diff__Pruned.py — Test_Vault__Diff__Pruned.

tests/unit/core/test_Vault__Regex_Guard.py

9.4 CLI tests

FileCovers
tests/unit/cli/test_CLI__Blame.pyParser, formats, exit codes, notes on stderr
tests/unit/cli/test_CLI__Grep.pyTop-level and file grep both reach cmd_grep; arguments after --; exit codes through SystemExit
tests/unit/cli/test_CLI__History__Renames_Pickaxe.py-M90, --no-renames, --rename-limit, --follow, the lifted filter refusals and the kept ones
tests/unit/cli/test_CLI__B07__Namespace_Moves.pyTOP_LEVEL_COMMANDS includes grep

9.5 Security tests

FileCovers
tests/security/test_Security__Known_Gaps.pyNew TM-R4x: raw terminal escapes printed by grep and blame (plain) and escaped in --json
tests/unit/core/actions/history/test_Vault__Blame.py B12The lying-plan case. It is a design guard, not a fix of a past finding, so it stays in unit tests and is cited in TM-R42's evidence column.

9.6 Acceptance checklist (implementation and deployment)

Tests

Renames

Blame and grep

Pickaxe

Partial clones

Performance

Deployment

10. Implementation steps (ordered commits)

#CommitContentsEstimate
1refactor: Vault__Regex_Guard shared by --grep and the new search commands5.9 guard; Vault__Log_Filter delegates; tests moved0.25 d
2feat(history): content paths, blob reader, path historySafe_Str__Content_Path, Vault__Path_Quote, Vault__Blob_Reader, Vault__Path_History; log_file on it0.5 d
3perf(diff): history show/diff/log --patch read only what changedGolden captures first (separate commit), then the pruned diff (5.5) and Vault__Line_Diff for hunks1.25 d
4feat(diff): rename detection (exact, basename, similarity) in diff, show, logVault__Renames, schemas, options, headers, --stat scores, JSON files_renamed, --follow1.5 d
5feat(history): sgit history blameVault__Blame, CLI__Blame, three formats, partial-clone behaviour1.5 d
6feat: sgit grep (working copy and revisions)Vault__Grep, CLI__Grep, top-level and file grep, Rule 9 edit1 d
7feat(history): log -S / -G (pickaxe)Vault__Pickaxe, dispatch changes0.5 d
8perf(pack): blame and --follow take the snapshot index's file-commits as a verified plan; pickaxe takes changes5.6 acceleration, B11–B120.75 d
9docs: CHANGELOG, threat model, git-users mapping, measurementsTM rows, numbers on the 76k vault0.25 d
Total≈ 7.5 days

The roadmap's 4–6 days (2.4 + 2.5 + 2.7) did not include step 3 (the pruned diff) or step 8. Step 3 is needed: blame, renames and pickaxe on a large vault are unusable on top of the current full-decrypt diff. Step 8 can ship later.

If design 01 lands first with its own line differ, step 3 uses it and Vault__Line_Diff is not written.

11. Open decisions for Dinis

#DecisionOptionsRecommendation
D1Where grep lives (Rule 9)(a) sgit grep top-level + sgit file grep; (b) sgit file grep only; (c) sgit history grep(a): a daily verb over files, the same pattern as cat/ls/write; one deliberate edit to TOP_LEVEL_COMMANDS
D2Regex dialect(a) Python re for all, -E/-P accepted as synonyms; (b) emulate Git's default POSIX BRE(a): BRE emulation is a parser of its own for little gain; documented as a known difference (\(…\) in BRE is a group, in ours a literal parenthesis)
D3grep exit codes(a) Git's: 0 match, 1 none, and 2 for errors (grep(1)); (b) sgit's usual 1 for errors(a): scripts written for git grep/grep rely on 1 = no match; errors must differ from that
D4Renames on by default, and JSON(a) on by default (Git ≥ 2.9), a renamed file leaves files_added/files_deleted and appears in files_renamed; (b) off by default, -M opts in; (c) on, but JSON keeps listing renamed files in added/deleted too(a) with --no-renames for the old output. An agent counting files_added sees a change, noted in the CHANGELOG. If an existing consumer is known, (c) for one release.
D5Line diff algorithm(a) Myers (own, shared with design 01); (b) difflib with autojunk=False(a): blame attribution matches Git's on common files; (b) differs on files with many repeated lines
D6Blaming a binary file(a) refuse unless --text; (b) blame it as Git does(a): an agent blaming an image gets noise
D7Grep in a selective working copy(a) on-disk files only, plus a note pointing at sgit grep <p> HEAD; (b) also read the head's blobs for files not checked out(a): the working copy is what Git's default searches; (b) can fetch gigabytes silently
D8Blame's author column(a) branch name from the index, key id fallback; (b) key id always(a), -e for (b)
D9On-demand fetch default for blame / grep <rev> / pickaxe(a) fetch when a remote is configured, --offline to forbid; (b) never fetch, tell the user to sgit fetch(a): what history show does today (A3)
D10Renames in sgit status(a) later, separate item; (b) now(a): Git shows them for staged changes only; sgit's status would need its own rule
D11Copy detection (-C)(a) out of scope; (b) include(a)
D12blame --ignore-rev / --ignore-revs-file(a) follow-up (needs Git's "fuzzy" line matching to be useful); (b) include the simple form (skip the commit, pass all its lines to its parent)(a)

This page as markdown · its source, admin/content/research/git-parity-design-blame-grep.md