Design reference for astvcs: on-disk layout, structural diff and merge, locking, network sync, and maintenance commands. For a project overview and quick start, see README.md. For every CLI flag and subcommand, see commands.md. Git merge/diff drivers (same engine, no .astvcs/): git-integration.md.
User-facing boundaries (in scope vs out of scope) are summarized in the README scope section. This document describes how those features are implemented.
States. Each commit writes a content-addressed manifest (states/{manifest_id}.json, where manifest_id = hash_manifest(manifest)) and a timeline entry with a distinct commit id (timeline/{commit_id}.json). Commit ids hash manifest content plus parents and metadata so parallel branches that reach identical trees keep separate messages and ancestry. Refs and HEAD point at commit ids. Merge states have two parents. Identical file content is stored once in the blob store; manifests hold only path -> blob hash. Committing with no file changes is a no-op. A commit may legitimately have an empty manifest when every tracked file was deleted. Manifest load verifies that each timeline entry's manifest, parents, and metadata hash to the commit id on disk; a wiped or inconsistent inline manifest is rejected as corruption while a consistent empty tree is accepted. Load prefers states/{manifest_id}.json when present and otherwise falls back to the verified inline manifest in the timeline entry (including legitimately empty trees).
Repository discovery. Repo::open and the default --repo path walk parent directories until .astvcs/ is found, so commands work from subdirectories of the repository root without passing an explicit root path.
Author identity. Timeline entries record author_name and author_email metadata for every state created by commit, merge, revert, and cherry-pick. Identity is resolved at state-creation time from, in precedence order: ASTVCS_AUTHOR_NAME / ASTVCS_AUTHOR_EMAIL environment variables, repository-local config.json (author object), then global ~/.astvcs/config.json. If none are set, those commands fail with an actionable error rather than guessing from the OS user account. identity set writes the author object; identity clear removes it from repository or global config only (other config keys are preserved). identity clear does not unset environment variables and does not rewrite author fields on existing timeline entries. identity get reads config only (no env); after clearing a repository override, it reports no repo identity even when global config or env vars still satisfy commit. The initial empty root state and other pre-existing timeline entries without author fields deserialize with empty author strings. Author metadata is part of the commit id hash; manifest ids remain hash_manifest(manifest) only so unchanged file content still deduplicates in states/ and the blob store.
Structured errors. Repository operations return RepoResult<T> (Result<T, RepoError>). RepoError carries a kind enum (for example lock_contention, dirty_working_tree, merge_conflict, missing_identity), a human-readable message (matching legacy string output for .contains(...) compatibility via Deref to str), an optional non-serialized concise CLI presentation, and optional path / reference fields. Library Display, Deref, and JSON always use the complete message. Plain CLI output uses concise when available; --details restores the complete message. The CLI accepts --json on any command to print one JSON object on stderr on failure.
HEAD. The HEAD file holds either a branch name or a state id (detached HEAD). status, diff, and commit compare against the checked-out state, not the branch tip when detached. reset moves the branch tip or detached HEAD; revert creates a new state that undoes a prior state on top of HEAD.
Working tree materialization. merge, checkout --branch, checkout --state, and hard reset materialize a state manifest to disk and sync index.json. Dirty-tree refusal and --force clobber warnings are centralized in a shared materialize guard (checked before refs, timeline writes, and disk sync) so every command that overwrites the tree behaves the same: refuse by default, warn per path with --force. The working tree is dirty when it differs from HEAD or when staging.json has staged entries (staged changes would be lost on materialize). Unstaged-only edits block materialize the same as before; staged changes also block unless --force. Hard reset to the current tip and checkout of the branch or state already at HEAD may materialize without --force to repair index/disk drift. reset --soft and no-op reverts skip materialization. reset --mixed moves the ref and syncs index.json without touching disk. Materialization clears staging.json entries.
Staging index. .astvcs/staging.json holds staged path entries (blob_id, content_kind, mode, or deleted: true). index.json remains the HEAD baseline (last committed manifest). After the first add, commit snapshots staged paths only; empty staging with pending working-tree changes errors with nothing staged; use astvcs add. Repositories that never run add keep legacy whole-tree commit behavior. status uses git-style two-column labels (staged vs HEAD, unstaged vs effective index). diff without flags shows unstaged changes; diff --staged shows staged vs HEAD. merge and cherry-pick refuse when staging is non-empty.
Stash. .astvcs/stash/ stores numbered JSON entries ({ id, message, base_state_id, created_at, manifest }) and an optional stack.json listing stash ids (newest at index 0). stash push writes changed paths as content-addressed blobs, saves the entry, then materializes HEAD (clearing staging). stash apply / stash pop three-way merge only paths in the stash manifest onto current HEAD and write results to the working tree only; other tracked files are left unchanged; conflicts abort without side effects; pop drops the entry on success. stash drop and stash clear remove entries from the stack and delete their JSON files without applying changes or requiring a clean working tree.
Rebase. .astvcs/rebase-state.json records an in-progress linear rebase (branch, upstream, onto, original_tip, current_head, remaining, conflicted). rebase <upstream> collects single-parent commits from the branch tip down to the merge base with upstream (exclusive), oldest first, and replays each onto current_head via the same three-way merge planner as merge (plan_three_way_unlocked). Replay conflicts materialize the partial merge to disk without conflict markers; rebase --continue accepts multiple --resolve path:ours|theirs flags for the same replay step. rebase --abort restores original_tip. v1 has no interactive rebase editor.
Cherry-pick. cherry-pick <ref> applies one state's changes onto HEAD as a new commit using the same replay geometry as rebase (not revert). Three-way roles:
| Role | State |
|---|---|
| base | Parent of the cherry-picked commit |
| left | Current HEAD |
| right | Cherry-picked commit (target) |
Revert inverts this (base = target, left = parent, right = HEAD). Cherry-pick applies right vs base onto left. Merge commits and the root state are rejected. Conflicts abort with no side effects (same contract as merge); unlike rebase, no in-progress state file is written.
Blame (line-based v1). blame <path> walks HEAD ancestors along the linear first-parent chain (linear_timeline_parent). For each commit (newest to oldest), it compares parent vs child file content at the path using line-oriented diff (diff_text). Lines introduced or modified in the child are attributed to that timeline entry (state id, author metadata, message). Unchanged lines relocated in the child (exact line text still present in the parent) are treated as moves, not edits. Line indices are mapped backward through the chain to HEAD line numbers, including content-based fallback when diff alignment breaks on reorder. Remaining unattributed lines at the root are credited to the oldest commit where the file exists. AST files are unparsed to text for line-based blame; structural intent blame (which AST node last changed a region) is out of scope for v1. Binary files and symlinks return actionable errors. Merge commits block further history walk with an error.
Bisect (linear v1). .astvcs/bisect-state.json records an in-progress bisect (original_head, original_branch, good, bad, skipped, candidates, low, high). bisect start builds candidates by walking the first-parent chain from bad back to good (commits strictly after good through bad, oldest first). good must be a linear ancestor of bad; merge commits on the path error. bisect run binary-searches candidates, checking out midpoint states and classifying them via a user script (exit 0 = good, 1 = bad, 125 = skip). Skip removes a candidate without classifying it; skipping every candidate errors and leaves state until bisect reset. Like client hooks, bisect run calls suspend_repo_lock before spawning the script and resume_repo_lock after so nested astvcs commands succeed. bisect reset restores the saved checkout. DAG or merge-heavy histories are out of scope for v1; bisect requires linear first-parent ancestry between good and bad.
Repository locking and atomic writes. Every CLI command that reads or writes refs, HEAD, the timeline, the blob store, index.json, staging.json, or the working tree acquires a single exclusive advisory lock on .astvcs/repo.lock for the duration of that logical operation. astvcs uses one coarse exclusive lock for local commands: concurrent readers against a writer materializing the tree would be unsafe, and staging writes must stay consistent with commits. The lock is OS advisory (flock on Unix, LockFileEx on Windows) via std::fs::File::try_lock, not a marker file; when a process exits or is killed the kernel releases the lock, so a stale lock cannot permanently deadlock the repository. If another process holds the lock, astvcs fails fast with repository is locked by another process; cannot acquire <path> rather than waiting, because local CLI contention is rare and an immediate error is clearer than a silent hang. Reentrant acquisition on the same thread allows nested repo calls without double-locking. Network sync entry points (fetch, push, remote config) acquire the same lock once per operation. The lock file descriptor is cached per thread and explicitly unlocked (not closed) between commands so sequential in-process repo calls on Linux do not fail reopening repo.lock with WouldBlock. Before running client hooks or bisect test scripts, mutating commands call suspend_repo_lock to release the OS lock while keeping the cached descriptor open, then resume_repo_lock to re-acquire after the subprocess exits. That lets hooks and bisect scripts invoke astvcs again without self-deadlock.
HTTP serve concurrency. astvcs serve does not hold advisory repo.lock for the server lifetime. An in-process RwLock<Repo> allows concurrent GET/HEAD handlers while PUT handlers take the write lock. Immutable content-addressed reads (blobs, states, timeline entries, shallow ancestry) skip advisory locking and use unlocked repo read paths so multiple clients and local CLI commands can proceed in parallel. Ref and config reads also skip advisory locking: each ref or config file is read atomically, so serve reads do not block local CLI and vice versa (reads may be momentarily stale during a concurrent CLI ref update). Each PUT acquires the write lock, then tries advisory repo.lock for the duration of that upload; if local CLI holds the lock, serve returns HTTP 503 with plain body repository locked. Writes serialize with each other and with no cross-process lock ordering that could deadlock CLI and serve on the same machine. remote-serve remains single-threaded over stdin and uses the standard locked dispatch path per request.
Client hooks. Optional executable scripts under .astvcs/hooks/ run as child processes during mutating operations. v1 hooks: pre-commit and commit-msg (before commit persist, only when the commit would create a new state), pre-merge (after a clean merge plan, before finish_merge writes), and pre-push (before upload, when the push would send new objects). Missing hooks are skipped. Non-zero exit aborts the operation with hook_failed. Pass --no-verify on commit, merge, pull (merge step), or push to skip hooks. Hooks run with cwd = repository root and environment variables ASTVCS_ROOT, ASTVCS_BRANCH (empty when detached), ASTVCS_HEAD_STATE_ID, plus hook-specific vars (ASTVCS_COMMIT_MSG_FILE, ASTVCS_MERGE_BRANCH, ASTVCS_REMOTE). The commit message is written to .astvcs/hooks/commit-msg-input before commit-msg; the hook may edit that file. On Windows, .cmd/.bat hooks run via cmd /C, .ps1 via powershell -NoProfile -File; on Unix, executable hooks run directly, otherwise sh hookpath.
On-disk format versioning. Layout migrations are tracked in config.json as format_version (separate from the legacy version field, which records the config schema revision and remains 2 on init). format_version absent or 0 means a pre-format-versioning repository. New repositories write format_version: 1. Migrations run on the first outermost repo_lock acquisition (alongside stray temp cleanup), in order from the stored version to the current version, each step using write_atomic_json on config.json or other metadata as needed. Rules: each migration must be idempotent; each step is atomic; refs and HEAD are never advanced before on-disk data is consistent. Optional new JSON fields with serde(default) do not require a format bump. fsck reports unknown format version when format_version is greater than the binary supports (warning in the report; does not auto-migrate down).
Single-file metadata writes (HEAD, branch and remote refs, index.json, state/timeline JSON, blob payloads, config.json, remotes.json, and working-tree files during materialization) use same-directory temp files with the .astvcs-tmp suffix followed by rename into place, so a crash mid-write leaves either the previous complete file or the new complete file, never a partial target. Multi-file materialization is not atomic as a unit: each path and index.json are independently atomic, but the operation as a whole can stop between files; the exclusive lock prevents another process from observing that window, and index.json is written last so a crash mid-materialize leaves the old index until the command completes or is retried. At the start of each outermost locked command, stray .astvcs-tmp files are removed when the canonical file already exists; orphan temps without a canonical target are left alone. Mutating commands update refs and HEAD after disk materialization and index.json so a failed materialize does not advance branch tips.
Merge planning is commit-only. Three-way merge plans are built from blob manifests at the merge base, HEAD, and the other branch tip (load_state_files only). The working tree is not consulted, so a forced merge cannot incorporate uncommitted edits into conflict detection or the merged file set; --force only clobbers dirty paths when writing the already-computed plan to disk.
Branches. Local branch tips live under .astvcs/refs/heads/. branch create --from resolves <ref> with the same rules as merge and reset (local branch, tag, remote-tracking ref, or commit id). branch remove deletes a ref file only; it refuses the checked-out branch and the last remaining branch. Unmerged commits do not block removal because states are content-addressed and remain in the timeline and blob store until gc --prune removes unreachable blobs and optionally gc --prune-history removes unreachable state metadata. When the removed branch is config.json default_branch, config is updated atomically: prefer main if it still exists among remaining branches, otherwise the lexicographically first remaining branch name. branch create sets default_branch to the new branch when the configured default ref is missing (dangling config). clone checks out the remote default_branch from upstream config.json.
Tags. Lightweight tags live under .astvcs/refs/tags/<name> as a single state id per file (same atomic write pattern as branch refs). v1 has no annotated tag objects. Tag names cannot contain / or ... Tags are resolved after local branches and before remote-tracking refs. Tag tips are included in reachability walks so tagged states stay reachable until the tag is removed. tag remove deletes the ref only; timeline and blobs remain until gc.
Reachability and garbage collection. A commit is reachable if it is the root empty state (0 repeated 64 times), or if it is reachable by walking parent links starting from every local branch tip, every tag tip, every remote-tracking branch tip, and the current HEAD state when HEAD is detached. Manifest files under states/ use content-addressed manifest ids (hash_manifest), which may differ from commit ids; reachability records both each visited commit id and its manifest id. A blob is reachable if it appears in the manifest of any reachable commit. The shared reachability walk in store/reachability.rs is read-only and runs under the repository lock; gc and fsck both call it.
gc uses a two-tier retention model. Tier 1 (default safe): --prune deletes unreachable blobs from loose storage and the pack index. Tier 2 (destructive, opt-in): --prune-history deletes unreachable state metadata: both .astvcs/timeline/{id}.json and .astvcs/states/{id}.json. The root empty state (ROOT_STATE_ID) is never deleted. Unreachable states are those not in the reachable_from_tips result; the same ref tips apply as for blob GC (branch tips, remote-tracking tips, detached HEAD).
By default gc is a dry-run for both tiers and reports unreachable blob count, unreachable state count, and reclaimable bytes for each. Unreachable timeline entries and state manifests are kept until --prune-history so you can still checkout --state <id> after all refs to a commit are gone. That audit-log retention was the original deliberate choice; operators who prefer disk over recoverability can opt in to history pruning. After --prune-history, detached checkout by id of a pruned state fails because the timeline entry is gone. States retained when history is not pruned remain checkoutable by id.
Blob pack storage. New commits still write loose sharded JSON files under .astvcs/blobs/. Run repack to pack loose blobs into zstd-compressed pack files under .astvcs/packs/ with an index.json mapping blob ids to pack offsets. Reads check loose files first, then the pack index. Content addressing is unchanged: blob ids remain SHA-256 over the canonical serialized FileContent JSON. Delta encoding (prefix/suffix against a same-shard base blob) is used only when it beats plain zstd compression. Packed blobs participate in reachability, gc, fsck, and network sync the same as loose blobs.
Repository integrity (fsck). Default fsck is report-only. It checks: state manifests referencing missing blobs; refs pointing to commit ids with no timeline entry; timeline entries with no resolvable manifest (missing state manifest); states/ files not referenced by any timeline entry (orphaned state manifest); HEAD naming a branch with no ref file; index.json entries inconsistent with HEAD (wrong state_id, paths absent from HEAD manifest, or index present while HEAD is invalid); pack index entries that fail decompression or hash verification; orphan .astvcs-tmp files whose canonical target does not exist (the cases cleanup_stray_temp_files leaves alone); and config.json format_version greater than the binary supports (unknown format version). Unreachable states that were intentionally retained are not reported as errors; after gc --prune-history removed them, they are simply absent. Network ref updates sync index.json when the pushed branch is HEAD; fsck does not compare the working tree to HEAD.
fsck --repair applies conservative fixes under the repo lock: rewrite index.json from HEAD when HEAD is valid and the index is inconsistent; remove stray .astvcs-tmp files when the canonical file exists. It refuses when HEAD names a missing branch while other local branches exist. It never repairs missing blobs, pack corruption, or missing state manifests. fsck --prune-refs deletes dangling local branch refs, tag refs, and remote-tracking ref files (never the HEAD file). Repairs run before a full re-check; applied fixes are listed in the output. Missing blobs and unreachable history still require gc --prune and optionally gc --prune-history after refs reflect the history you want to keep.
On-disk layout.
.astvcs/blobs/ content-addressed file payloads (sharded by hash prefix; loose writes)
.astvcs/packs/ optional zstd-compressed pack files and index.json (via repack)
.astvcs/states/ content-addressed manifests (`hash_manifest`)
.astvcs/timeline/ commit ids, parent links, and metadata
.astvcs/refs/heads/ branch tips
.astvcs/repo.lock exclusive advisory lock (empty; OS lock on open)
HEAD branch name or state id
index.json last committed manifest (working-tree baseline)
staging.json staged paths overlay (`active` flag set on first `add`)
stash/ numbered stash entries (`0.json`, …) plus `stack.json` index (0 = newest)
rebase-state.json in-progress linear rebase queue (absent when idle)
bisect-state.json in-progress bisect search (absent when idle)
scan-cache.json mtime/size snapshot for incremental working-tree scans
config.json repository settings (`version`, `format_version`, `default_branch`, optional `author`)
format_version in config.json tracks on-disk layout migrations (see On-disk format versioning above). The legacy version field is the config schema revision.
Working tree scan. Honors .gitignore, .astvcsignore, and git exclude files (ripgrep semantics). Always skips .astvcs/ and .git/. Non-UTF-8 path names are not tracked; file content may be binary.
Incremental scan cache. status and commit reuse .astvcs/scan-cache.json when HEAD matches the cache head_state_id and the cache version is current. The sidecar stores per-path { mtime, size, is_symlink, unix_mode } and per-directory { mtime, child_count } from the last successful scan. An incremental pass re-stats cached paths and index paths (detecting removals and edits), prunes unchanged directories only when both mtime and child count match and no cached descendant directories exist beneath the path, and skips the tracked-file load (parse and mode detection) for paths whose metadata and raw-byte digest still match the last verified snapshot against HEAD. Pass --full-scan on status or commit, or -v / --verbose, to force a complete walk and full tracked loads. The cache is invalidated on checkout, merge, and hard reset materialization (via materialize_state_inner), and rebuilt on the next scan. It is updated after every scan and its head_state_id is advanced after a successful commit. Updates run under the repository lock and use atomic writes like other metadata.
Remaining limitations. Incremental scans still read raw bytes to confirm verified paths; the win is skipping AST parsing and symlink or mode classification on unchanged files. Directory pruning depends on accurate child counts; if a filesystem reports a stale count, use --full-scan or -v.
Binary files. Files whose bytes contain a NUL or are not valid UTF-8 are stored as FileContent::Binary blobs. UTF-8 text (including known text-only paths and parse-fallback sources) continues to use AST or text blobs. Binary payloads share the same content-addressed blobs/ tree as AST and text: each blob is a JSON envelope {"kind":"binary","bytes":"<base64>"} hashed by the serialized bytes (same sharded layout as other kinds). A separate blobs-bin/ tree was not added: one store keeps deduplication, gc, fsck, and network sync unified; the kind field distinguishes encodings on read. There is no maximum file size policy beyond available disk and memory. Materialization writes raw bytes via atomic::write_atomic. status reports binary paths as added, modified, or removed like text. diff and diff --state print path headers and (binary file - content diff omitted) instead of a byte-level diff. Three-way merge treats binary paths as opaque whole-file replace only (no structural or line merge); add/add with different bytes conflicts like text add/add.
File modes and symlinks. Manifest entries are path -> { blob, mode } where mode is regular (default, serialized as a plain blob id string for backward compatibility), executable, or symlink. Mode metadata is separate from blob content hashing: the same text blob id with different modes produces different manifest ids (hash_manifest appends the mode tag only for non-regular entries). Symlink targets are stored as FileContent::Symlink blobs ({"kind":"symlink","target":"..."}) referenced from the manifest. On Unix, checkout creates symlinks via symlink(2) and restores the executable bit (chmod +x) for executable entries. On Windows, astvcs attempts symlink_file; if creation fails (common without Developer Mode or elevation), it emits warning: and skips the link rather than copying the target. CI enables Developer Mode on windows-latest so symlink integration tests run on both platforms. Executable detection on Unix uses the file mode bit; on Windows, .sh/.bash/.zsh files with a #! shebang are stored as executable (manifest round-trip; +x is not applied on disk). The working-tree scan includes symlinks (not followed). Merge treats symlinks as opaque whole-path values like binaries; replacing a symlink with a regular file (or vice versa) on one branch conflicts; mode-only edits merge when one side changed the mode from base. Absent paths are not treated as regular during three-way mode merge.
Supported extensions are parsed with tree-sitter into an AstGraph DAG. Each node has a NodeId, a NodeKind, an optional payload (literal text, identifier name, etc.), and ordered children.
NodeId (one snapshot). NodeId hashes kind, payload, and child ids. It names a node inside one parsed graph. A payload edit (for example 1 to 2 on a literal) produces a new id for that node. Applying a mutation can reseal ancestors to new ids when child ids change.
Cross-version continuity. astvcs does not assign persistent node ids across commit calls. Continuity is reconstructed: diff_graphs aligns an old graph to a new graph, then emits mutations (EditPayload, RenameIdentifier, InsertSubtree, SetTrivia, and others) that reference nodes in the old graph. Three-way merge diffs each branch from the merge base and applies those mutations to a copy of the base.
| Extensions | Language |
|---|---|
.rs |
Rust |
.py, .pyw |
Python |
.js, .mjs, .cjs |
JavaScript |
.go |
Go |
go.mod |
Go module manifest |
.c, .h |
C |
.json |
JSON |
.toml |
TOML |
.yaml, .yml |
YAML |
.ts |
TypeScript |
.tsx |
TSX |
.cpp, .cc, .cxx, .hpp, .hh |
C++ |
.java |
Java |
.cs |
C# |
.swift |
Swift |
.kt, .kts |
Kotlin |
.zig |
Zig |
.sql |
SQL (tree-sitter-sequel on crates.io) |
.sh, .bash |
Bash |
.html, .htm |
HTML |
.css |
CSS |
All other paths use line-oriented text blob storage when the file is valid UTF-8 without NUL bytes. NUL-containing or invalid UTF-8 content is stored as a binary blob regardless of extension. Parse failures on supported extensions fall back to text and emit warning: on stderr. Known text-only paths (for example .gitignore, .md, .txt, go.sum, .ps1) store as text blobs silently; use -v to see stored as text blob notices. Unknown extensions warn once per path per process. Commits are not blocked on text fallback (partially broken sources still need versioning).
When an AST-capable path is stored as a text blob on either HEAD or the working tree, status appends (text fallback) to the path line and diff prints (text fallback - structural diff unavailable) in the path header plus a parse mode: intent when left and right differ in storage kind. Use -v to see notice: … text fallback (reason) detail on stderr in addition to warnings.
Extension detection uses the substring after the last . in the path (case-sensitive). A file named types.d.ts is treated as .ts, not a separate extension.
Materialization uses trivia-aware unparsing (see Working tree materialization above): leading gaps before each child are stored at parse time and replayed on output. When a named tree-sitter node spans past its last leaf (common in Go blocks), the gap before the next sibling is taken from the previous sibling's rightmost leaf end byte, not the named node's extended end byte. Trailing EOF line comments capture comment body bytes that tree-sitter leaves outside leaf tokens; inline trailing comments keep body text in sibling leading trivia. CSS compact value nodes (for example integer_value for 10px) are flattened to single leaf nodes with the full source span as payload so sibling dimensions do not collide on content-addressed NodeId and trivia keys.
- Parse old and new sources into graphs.
- Align children between old and new. When sibling
NodeIdsequences are equal (identical reparse or unchanged list), pair by index and recurse without LCS. Otherwise use hash-anchor passes on wide sibling lists (old.len() * new.len() > 48), or the full-list LCS path:- Wide-list identity pass: pair each content-addressed
NodeIdthat appears exactly once on each side first, so unchanged unique siblings stay matched when another same-kind empty-payload sibling is appended or prepended (for example several GoFunctionnodes). Without this, LCS on(kind, payload)alone can shift every match by one. Duplicate ids (commas, repeated literals) are left for later passes. - Wide-list structural LCS: LCS on structural
(kind, payload)keys for remaining unmatched children (not bareNodeId), so edited parents that kept kind and payload can still pair when their child lists diverged. - Narrow-list id pass:
lcs_pairson the full siblingNodeIdsequence; duplicate content-addressed tokens are then re-paired by ordinal position (preferring same list index when one occurrence was edited away, otherwise minimizing index distance) so LCS subsequence skew does not leave a phantom separator unmatched. - Key pass (wide): in-order zip within each
(NodeKind, payload, child_count)bucket; role pass (wide): same for(NodeKind, child_count). - Bounded LCS: when the unmatched cross-product is at most 48, run full-list role then key LCS on the remainder (same anchor semantics as before).
- Fingerprint pass: hash buckets of preorder structure signatures; pair when bucket size is 1 on each side.
- Fallback: position-aware structural and payload-editable leaf pairing, then delete/insert.
- Wide-list identity pass: pair each content-addressed
- Emit mutations anchored to the old graph:
EditPayload,InsertSubtree,DeleteSubtree,RenameIdentifier,MoveNode,MoveSubtree,ReorderChildren,SetTrivia,SetRootTrailingTrivia, and others. Insertions use sibling anchors (before: Option<NodeId>,before_occurrence) rather than absolute indices, so prepending one node does not emit move cascades for trailing siblings.before_occurrenceis resolved against the old parent's child list (not the new graph). List separators (,/;) anchor before the next new list element when one follows; field/parameter inserts before a closing}when the next anchor would be a comma token. Phantom separator inserts (same content-addressed comma already present after the previous matched sibling) are omitted. Pending inserts emit list elements before separators so apply order stays valid. When matched siblings keep the sameNodeIdbut leading trivia changes (for example trailing comment text stored before the next sibling token),SetTriviacaptures the gap. Same-id internal nodes still recurse into children so trivia-only edits and reorder-with-trivia changes are not skipped.
Alignment is heuristic. Wrong sibling pairing can still produce delete+insert instead of EditPayload, or mis-anchored mutations. The identity-demo fixture exercises literal EditPayload and cases where alignment fails (rename conflict).
Sibling fallback pairing. After hash-anchor and bounded LCS passes, unmatched structural siblings are paired by kind, preferring equal child_count and smallest index distance (so swapped same-shape siblings are not matched to the first same-kind candidate in scan order). Unmatched payload-editable leaves use the same proximity rule. Pass -v to see notice: diff: … fallback paired siblings … when this path runs.
Structure fingerprints. Fingerprints used for MoveSubtree pairing are a preorder (kind, child_count, payload) list. Payload is included for editable leaves (Literal, Identifier, Token, Unknown) so subtrees that share shape but differ in literal text can be distinguished. Fingerprints still ignore NodeId and non-editable node text.
Known limitations. Ambiguous siblings with identical structure and identical editable payloads (for example two functions whose bodies are both 1) are not uniquely pairable; MoveNode/ReorderChildren or delete+insert may still result. Cross-file subtree moves are out of scope (path rename only). Adding or removing children (for example a new comment sibling) can still force a looser structural match when no equal-child_count candidate exists. Delete+insert coalescing into EditPayload when fingerprints match is not implemented; full subtree replacement stays delete+insert.
Rename and move detection. Two problems are handled separately:
| Kind | Detection | Representation |
|---|---|---|
| Path-level rename | Pair removed and added manifest paths in detect_path_renames |
EditIntent::RenamePath in status/diff output (not delete+add) |
| Intra-file subtree move | Post-LCS pass in diff_children: unique bijective structure fingerprint match |
Mutation::MoveSubtree → EditIntent::MoveSubtree |
Path rename pairing. Text files pair only on exact content (semantic_eq). AST files also pair when diff_graphs reports edit-only changes (no DeleteSubtree or InsertSubtree), surfaced as rename with edits, and only when source and destination share the same file extension (cross-extension delete+add stays unpaired unless byte-identical). Near-identical text paths stay unpaired (delete+add). Merge planning correlates base paths through per-side rename maps; conflicting renames of the same base path to different destinations conflict; keeping the source path while the other branch renamed it to a destination that HEAD also modified independently conflicts.
Intra-file move scope. MoveSubtree runs after id/role/key alignment when exactly one unmatched old child and one unmatched new child share a structure fingerprint (preorder kind tree with editable-leaf payloads; NodeId ignored). Ambiguous siblings (multiple same-shape items with identical fingerprints) are left to MoveNode/ReorderChildren heuristics or delete+insert. Merge treats MoveSubtree/MoveNode as disjoint from payload edits on the same node_id, so a move on one branch and a body edit on the other apply together. Cross-file function moves are out of scope (path rename only).
Planned optimization (AST blob size). AST blobs are stored as canonical JSON (kind: "ast") and blob ids are content hashes of that JSON. A more compact snapshot encoding (for example kind: "ast_compact") would require a format-version migration and dual deserialize; micro-optimizations that skip empty fields would also change existing blob hashes. Storage compaction is deferred until a backward-compatible migration path exists.
Edit intents. Raw mutations are classified for human-readable output (EditLiteral, RenameIdentifier, RenamePath, MoveSubtree, PrependComment, InsertStatement, etc.). Default diff uses compact labels without NodeId values and coalesces repeated formatting-only intents while preserving every semantic edit. Pass --details for detailed intent labels and raw mutations. -v includes the same details plus operational notices.
Alignment export and graphical viewer. diff_graphs remains mutation-only for merge and existing callers. diff_graphs_detailed shares the same recursion and also records AlignEdge values: each sibling pair as Match with an AlignMethod (Id, Key, Role, Lcs, Fingerprint, StructuralFallback, LeafFallback), plus Insert/Delete for unmatched children. Sibling alignment by NodeId pairs duplicate content-addressed ids in list order (e.g. multiple , tokens under parameters share the same hash). InsertSubtree insert anchors prefer old-graph siblings (scanning past pending inserts to the next matched sibling, e.g. before ) in parameter lists); when no such anchor exists, they fall back to the new-graph next sibling id (trailing expression inserts). MoveNode / MoveSubtree keep new-graph next-sibling anchors. diff --view builds a DiffViewDocument (graph snapshots, alignment, mutations, compact classified intents, optional unparsed source) and writes a self-contained HTML page. The first view is a change summary. Changed nodes and ancestors are expanded; unrelated branches are count-labelled placeholders whose children are created on request. n/p navigate changes, j/k navigate files, and a keyboard help dialog documents shortcuts. Raw IDs, alignment methods, mutations, and pipeline data remain in collapsed details. The viewer consumes real edges only; it does not invent confidence scores or persistent cross-commit node ids.
Text diff. Fallback files use Myers line diff via the similar crate.
Mutations locate children by node_id, not stored indices.
- Find the lowest common ancestor on the timeline (
merge-base). - Per-path three-way logic: add/add, delete on one side, modify/delete (keeps the modification), unchanged sides short-circuit inside
merge_files. - Overlap detection uses edit intents, ancestor checks (a deletion covering an edit inside its subtree), and precise insert-site checks. Mutations that are merge-equivalent on both branches are not overlapping; shared mutations are applied once from the left/ours batch (shared phantom punctuation inserts stay omitted entirely).
InsertSubtreemutations merge-equate only when parent,before,before_occurrence, and inserted subtree id match; substantive sibling inserts (functions, fields, declarations, decorators, attributes/annotations, and similar) at the same anchor with different subtree ids are disjoint and all apply in ours-then-theirs order (left/HEAD mutations before right/incoming). When several same-site inserts share one anchor, separator trivia for the 2nd+ insert is synthesized from the shared anchor'sSetTrivia(or\nby default) so newly adjacent siblings do not abut with an empty leading gap. Competing literal or punctuation inserts at the same anchor still overlap. First-time wraps that introduce a language-specific wrapper node (Pythondecorated_definition, Javamodifiers) still conflict when both sides wrap differently. Shared phantom commas at the same site are omitted from the combined batch. Sibling payload edits under the same parent merge when they touch different nodes. Disjoint structural edits apply in one batch with redirect rebasing (redirect/redirect_mapfollow one cascade hop per step and stop on cycles).EditPayloadandRenameIdentifiercarry an optionalparentscope and an optionaloccurrenceindex; when a content-addressed node appears in more than one parent (for example literal1in bothcall(1, …)andlet x = 1), apply uses copy-on-write so editing one occurrence does not remove the shared node from other sites. When identical siblings share oneNodeIdunder the same parent (for example repeated0literals in a list), diff stampsoccurrencefrom the matched sibling slot so each branch's edit stays distinct: merge treats different occurrences as disjoint when the edited node is duplicated under that parent, and apply replaces the intended slot while renumbering remaining duplicate-slot trivia so comma spacing is preserved. Sibling trivia diffs use the matched child index (not the first id match) soSetTriviatargets the correct occurrence. Nested edits inside a duplicated subtree (for example two identicalifblocks), including edits to different leaves under different duplicate occurrences, conflict rather than applying a broken graph; inherited scope tags (occurrence set when the leaf is not itself duplicated under its immediate parent) detect that case. Text merges use disjoint line edits.src/merge/language_merge_cases.rsholds per-language disjoint-edit fixtures exercised across every AST frontend.
Failed merges roll back atomically: HEAD, branch tips, working tree, and index.json are unchanged. Focused conflict output lists paths, both sides' intents, and overlap reasons. Merge and pull include --resolve syntax; rebase includes rebase --continue --resolve; revert labels the reverted parent and current HEAD; cherry-pick and stash omit unsupported resolution flags. Repeated overlap examples are limited per path with an omitted count. --details restores state IDs, raw mutations from each side, and every overlapping pair (same node, deletion covering a nested edit, same insert site, or same intent). Use merge --dry-run to preview, and diff --base --left --right to inspect both sides.
When conflicts cannot be merged structurally, merge --resolve path:ours|theirs picks the full file from HEAD or the other branch for that path only. The standalone CLI does not write conflict markers into the working tree. The optional Git merge driver does write <<<<<<< markers into %A on structural conflict (see git-integration.md).
Remotes are stored in .astvcs/remotes.json. Remote-tracking branch tips live under .astvcs/refs/remotes/<name>/. HTTP remotes may include an optional bearer token in remotes.json (stored in plaintext; file permissions are the operator's responsibility). Local path and file:// remotes ignore tokens.
Supported remote URLs:
| Scheme | Example |
|---|---|
| Local path | C:/repos/project or file:///C:/repos/project |
| HTTP | http://127.0.0.1:9421 (from astvcs serve) |
| HTTPS | https://127.0.0.1:9421 (from astvcs serve --tls-cert ... --tls-key ...) |
| SSH | ssh://user@host/path/to/repo or user@host:/path/to/repo (remote must have astvcs on PATH) |
Sync transfers content-addressed objects only: blobs, state manifests, timeline entries, branch refs, and tags. fetch downloads missing history, updates remote-tracking refs, and syncs all remote tags (even when --branch limits which branch refs are updated). It does not change local branches or the working tree. pull is fetch followed by merge of the remote-tracking branch into the current branch. Use reset or checkout --state with a remote-tracking ref (for example origin/main) or a tag name to inspect fetched commits without merging. push uploads missing objects, fast-forwards the remote branch (use --force to override), and uploads local tags missing on the remote (tag updates are not fast-forward checked). clone initializes a repository, fetches branches and tags from the remote, and checks out the default branch.
Shallow fetch and clone. Pass --depth N on fetch, clone, or pull to download at most N timeline entries counting from each branch or tag tip (N=1 is tip only), matching git shallow clone semantics. Omit --depth for unlimited history (default). The client uses GET /v1/timeline/{tip}/ancestry?depth=N over HTTP, SSH remote-serve (query on the path), or a direct repo walk for file remotes. Shallow boundaries are recorded in .astvcs/shallow.json as state ids where parent history was intentionally not fetched; boundaries clear on a full fetch (no depth) or when a deeper fetch imports the missing parents. Tag fetch during shallow sync applies the same depth limit per tag tip. Shallow repositories may fail merge-base, merge, and pull when a tip is a shallow boundary or the lowest common ancestor is not present locally; deepen history with fetch --depth (higher N) or a full fetch.
HTTP API: GET /v1/refs/tags returns a JSON map of tag name to state id; GET/PUT/HEAD /v1/refs/tags/{name} read or overwrite a tag tip as plain text.
The HTTP API uses /v1/ paths for blobs, states, timeline entries, branch refs, repository config, and shallow ancestry listing (GET /v1/timeline/{tip}/ancestry?depth=N returns {"states":["id",...],"shallow_boundary":null|"state_id"}).
HTTP authentication. astvcs serve accepts an optional bearer token via --token or the ASTVCS_SERVE_TOKEN environment variable (CLI wins when both are set). With no token configured, the server is open for local development. When a token is configured, the server fails closed: PUT on /v1/* always requires Authorization: Bearer <token>; GET and HEAD require the token unless --public-read is set. Wrong or missing credentials return HTTP 401 with a plain text body. Token comparison uses constant-time equality. The HTTP client transport sends the stored remote token on every request when configured. Local file remotes remain unrestricted.
TLS on serve. Optional --tls-cert and --tls-key PEM paths enable HTTPS via tiny_http's rustls backend (ssl-rustls feature). Both flags must be supplied together. Without them, serve listens on plain HTTP.
HTTPS client validation. HTTP remotes use reqwest with rustls. Certificate validation is enabled by default (fail closed on bad or self-signed certs). Pass --insecure on fetch, push, pull, or clone to call danger_accept_invalid_certs(true) for local dev with self-signed serve certs. Bearer tokens work over HTTPS the same as HTTP. --insecure does not apply to SSH remotes.
SSH remotes. SSH URLs use OpenSSH as the transport. The client runs ssh -o BatchMode=yes -o StrictHostKeyChecking=accept-new user@host astvcs remote-serve --repo <path> and speaks a newline-delimited JSON protocol over the SSH session stdin/stdout. Host key verification and authentication (keys, ssh-agent) are delegated to the system ssh binary. The remote host must have astvcs installed on PATH. Scp-style URLs (user@host:/absolute/path) require user@ to avoid mistaking Windows drive letters for remotes; the path must be absolute on the remote. Bearer tokens from remotes.json or clone --token are sent as Authorization: Bearer ... in protocol requests. When ASTVCS_SERVE_TOKEN is set on the remote (or --token on remote-serve), the same auth rules as HTTP serve apply.
remote-serve protocol. Internal subcommand astvcs remote-serve --repo <path> reads one JSON request per line on stdin and writes one JSON response per line on stdout. Request: {"method":"GET|HEAD|PUT","path":"/v1/...","body":"<base64 optional>","headers":{...}}. Response: {"status":200,"body":"<base64 optional>","error":"<text on failure>"}. Paths and semantics match the HTTP /v1/ API (config, refs, blobs, states, timeline). Shared dispatch lives in network/api.rs for HTTP serve and remote-serve.
src/
lib.rs
trace.rs stderr notice/warning output (notices gated by -v)
graph/
dag.rs AstGraph, snapshots, apply_batch
node.rs Node, NodeId, NodeKind
mutation.rs structural edit operations
edge.rs TriviaSlot
frontend/
languages.rs extension to tree-sitter language map
treesitter.rs parser and translator
textblob.rs text fallback
binaryblob.rs binary detection and working-tree load
symlinkblob.rs symlink target blobs
unparser.rs
diff/
lcs.rs longest common subsequence matching
align.rs hash-anchor sibling pairing helpers
ast_diff.rs structural diff; sibling alignment; `diff_graphs_detailed` / `AlignEdge`
text_diff.rs Myers line diff
path_rename.rs path-level rename detection
view.rs `DiffViewDocument` and self-contained HTML for `diff --view`
view/viewer.html change-first viewer assets (inlined via `include_str!`)
intent/
mod.rs edit intent classification and overlap reasoning
merge/
store/
atomic.rs same-directory rename writes, stray temp cleanup
blobs.rs content-addressed blob store
manifest.rs manifest entries, file modes, hash_manifest, hash_commit
tracked.rs TrackedFile (content + mode)
working.rs load working-tree paths with mode detection
error.rs RepoError kinds and structured failures
history.rs timeline walk and merge-base (LCA)
identity.rs author config resolution and persistence
integrity.rs gc and fsck (calls reachability)
lock.rs exclusive advisory repo lock (repo.lock); suspend/resume for hooks
hooks.rs client hook runner (pre-commit, commit-msg, pre-merge, pre-push)
reachability.rs ref-tip reachability walk (shared by gc/fsck)
rebase.rs rebase state and linear replay
cherry_pick.rs single-commit replay onto HEAD
blame.rs line-based blame along linear history
bisect.rs linear bisect state and binary search
git_import.rs import-git snapshot migration via git subprocess
walk.rs gitignore-style working tree scan (full and incremental)
scan_cache.rs scan-cache.json load/save and path stat helpers
repo.rs repository and CLI backend
network/
api.rs shared /v1/ request dispatch (HTTP and remote-serve)
transport.rs file, HTTP, and SSH remotes
ssh.rs SSH URL parsing and subprocess transport
remote_serve.rs newline JSON protocol for SSH
sync.rs fetch, push, clone
remote.rs remote configuration
serve.rs HTTP repository server
main.rs
bin/
merge_driver.rs Git merge driver (astvcs-merge-driver)
diff_driver.rs Git external diff driver (astvcs-diff-driver)
examples/
workflow-demo/ disjoint AST merge walkthrough
merge-demo/ add/add, deletion, and config fixtures
identity-demo/ literal EditPayload, sibling literal merge, rename conflict
same-file-demo/ same-file disjoint AST merge (rename + insert)
go-eof-insert-demo/ wide-list Go EOF append of distinct functions
tests/
integration.rs
git_drivers.rs
diff_git.rs
import-git is a one-way migration aid: it reads a local git repository via the git CLI and imports the HEAD tree snapshot into an astvcs repository as a single commit. It uses git rev-parse, git ls-tree -r HEAD, and git cat-file subprocess calls only (no libgit2). UTF-8 text paths are written to the working tree and committed with normal commit semantics (author identity required). The import commit includes only paths read from git HEAD; unrelated files on disk are not swept in. NUL-containing or invalid UTF-8 blobs are skipped with warning: on stderr and are not committed even when a same-named file exists on disk. Symlinks (git mode 120000) are imported when the target is valid UTF-8. Submodule entries (mode 160000) are skipped with a warning. The astvcs tree is synced to the git snapshot: paths tracked at astvcs HEAD but absent from git HEAD are removed from disk before commit. If the target repository has no .astvcs directory, import-git runs init first.
Git merge and diff drivers. Two additional binaries (astvcs-merge-driver, astvcs-diff-driver) call the same merge_files / diff_graphs library paths used by the standalone CLI, with no .astvcs/ access. On structural conflict for text/AST paths the merge driver exits nonzero and writes <<<<<<< markers into %A. Setup and limitations are in git-integration.md. Drivers do not change repository model semantics; they are a packaging surface for existing Git workflows.
Non-goals (v1 and beyond for full git parity):
- No git object hash compatibility (commit ids and manifest ids differ from git).
- No native
.gitdirectory mode for astvcs. - No bidirectional sync with git remotes or working trees.
- No full commit history import (snapshot only in v1).
- No entity-level (name+scope) merge rewrite; drivers keep node-level overlap rules.
Unit tests live beside modules under src/. tests/integration.rs exercises the CLI and library together. tests/git_drivers.rs covers the Git driver binaries; tests/diff_git.rs compares standalone merge outcomes to plain Git.
| Test | What it guards |
|---|---|
parse_all_supported_languages |
Every supported_extensions() entry and supported_special_paths() basename parses and validates |
edit_roundtrip_preserves_structure_across_languages |
Parse, trivial EditPayload diff, apply, unparse, re-parse: no structural drift; text matches edited source (Rust, Python, JS, JSON, TS, Go, HTML, CSS) |
rust_unparse_roundtrip_via_repo |
Commit and reload preserves Rust source bytes |
go_unparse_roundtrip_via_repo |
Commit, reload, and checkout preserve Go source bytes including block closing newlines |
same_file_demo_disjoint_merge |
Same-file rename + insert merge keeps formatting (stress test for alignment heuristics) |
go_eof_insert_demo_disjoint_merge |
Wide-list Go near-duplicate functions: both sides append distinct EOF functions |
cli_diff_view_writes_html_with_alignment |
diff --view writes temp HTML embedding path, intents, and alignment export |
cli_diff_view_large_file_keeps_change_first_controls |
Generated large AST diff keeps change navigation and lazy unchanged-tree controls |
viewer_javascript_indexes_all_file_modes_and_targets_insertions |
Executes shipped viewer logic for non-AST navigation and inserted-node targeting |
cli_diff_defaults_to_compact_intents_and_details_restores_mutations |
Default diff hides internals; --details restores raw mutations |
identity_demo_payload_edit_disjoint_merge_and_conflict |
Sibling literal merge and rename conflicts |
trailing_comment_and_literal_edit_merge |
Trailing comment text survives merge when a sibling literal is edited on the other branch |
cli_trivia_only_commit |
Whitespace-only formatting commit round-trips through the CLI |
cli_branch_remove_guardrails |
Branch remove: checked-out, last branch, not found, recreate name |
branch_create_from_remote_tracking_ref |
branch create --from resolves remote-tracking refs; unknown refs error cleanly |
create_branch_from_remote_tracking_ref, create_branch_from_state_id, create_branch_from_unknown_ref_errors_cleanly |
branch create --from ref resolution (unit, store/repo.rs) |
cli_reset_hard_soft_and_force |
Hard/soft reset, drift repair, force clobber warnings |
reset_modes_soft_mixed_hard_comparison |
Soft, mixed, and hard reset with dirty tree and staging |
reset_mixed_unstages_and_keeps_disk |
Mixed reset clears staging and keeps disk |
partial_commit_only_stages_paths, status_shows_staged_and_unstaged_columns, cli_commit_empty_staging_errors |
Staging index: partial commit, two-column status, empty staging error |
serve_requires_token_for_mutations, serve_read_requires_token_by_default, serve_public_read_allows_anonymous_get, serve_put_returns_503_when_advisory_lock_held, serve_concurrent_reads_during_writes |
HTTP serve bearer auth, TLS config pairing, advisory lock 503, concurrent reads during writes (unit, network/serve.rs) |
parse_remote_url_accepts_https, insecure_client_accepts_self_signed_cert, http_transport_sends_bearer_token |
HTTPS remotes, --insecure, client bearer token (unit, network/transport.rs) |
parse_remote_url_accepts_ssh, ssh_session_sends_bearer_token, remote_serve_io_get_config_put_blob_head_404 |
SSH remotes and remote-serve protocol (unit, network/ssh.rs, network/remote_serve.rs) |
repack_roundtrip_and_fsck, repack_fetch_push_roundtrip, gc_preserves_packed_blobs |
Blob repack and network round-trip after repack |
cli_revert_and_dry_run |
Revert conflicts, dry-run, and successful undo |
cli_revert_json_reports_revert_conflict |
revert --json emits revert_conflict on stderr without writes |
resolve_remote_ref_for_diff_merge_base_and_checkout |
origin/main-style ref resolution |
pull_merges_upstream_changes |
pull fetches and merges upstream commits |
pull_detached_head_requires_branch |
pull on detached HEAD requires --branch |
pull_merge_conflict_after_fetch |
Fetch succeeds; merge conflict leaves local branch unchanged |
stash_before_checkout |
stash push cleans tree so checkout succeeds without --force |
tag_create_and_list |
tag create, tag list, tag remove |
checkout_tag_detached |
checkout --state <tagname> detached at tagged state |
tag_fetch_push_between_repos |
Tags sync on fetch/push between file remotes |
shallow_clone_has_fewer_timeline_entries_than_full_clone |
--depth limits timeline entries vs full clone |
full_fetch_deepens_shallow_clone |
Full fetch (no --depth) downloads missing history and clears shallow boundaries |
merge_base_fails_on_shallow_clone_with_incomplete_history |
Shallow tips block merge-base and merge; --json shallow merge-base uses kind: other |
stash_pop_restores_files |
stash pop restores stashed file content to disk |
stash_pop_preserves_unstashed_tracked_files |
stash pop leaves tracked files outside the stash manifest on disk |
stash_pop_conflict_keeps_entry |
Conflicting stash pop aborts and keeps the stash entry |
stash_drop_discards_without_applying |
stash drop removes the entry without restoring stashed files |
stash_clear_removes_all_entries |
stash clear empties the stack without applying changes |
rebase_linear_success |
Feature branch commits replayed onto updated main |
rebase_conflict_abort_restores |
Replay conflict then rebase --abort restores tip and disk |
rebase_conflict_continue |
--resolve on rebase --continue finishes replay |
rebase_multi_path_resolve_continue |
Multiple --resolve flags on one rebase --continue finish a multi-path replay conflict |
cherry_pick_clean_commit |
Cherry-pick feature commit onto diverged main |
cherry_pick_conflict_leaves_head_unchanged |
Conflicting cherry-pick aborts without side effects |
cherry_pick_from_remote_tracking_ref |
Cherry-pick origin/feature after fetch |
blame_linear_two_commits |
Line blame attributes edits to correct commits in linear history |
blame_reorder_preserves_attribution_for_moved_lines |
Reordered unchanged lines stay attributed to the introducing commit |
bisect_linear_four_commits |
Bisect finds first bad commit via script in linear history |
bisect_run_skip_exit_125 |
bisect run exit 125 skips a candidate and still finds the first bad commit |
bisect_run_all_skips_leave_state_until_reset |
Skipping every candidate errors; --json reports invalid_input; bisect reset clears state |
bisect_run_releases_lock_for_nested_astvcs |
Bisect script runs nested astvcs status without lock error |
merge_remote_tracking_ref |
merge origin/main after remote ref update (unit, src/store/repo.rs) |
cli_reports_repository_lock_contention |
Lock held externally: CLI fails fast with lock path |
concurrent_repo_lock_fails_fast_with_actionable_error |
Second writer gets lock error; succeeds after release (unit, src/store/repo.rs) |
suspend_and_resume_releases_for_subprocess |
Lock suspend allows nested subprocess acquire (unit, src/store/lock.rs) |
hook_pre_commit_aborts_commit |
pre-commit exit 1 aborts commit; refs unchanged |
hook_commit_msg_edits_message |
commit-msg edits message file; timeline shows edited message |
hook_nested_astvcs_status_in_pre_commit |
pre-commit runs nested astvcs status; commit succeeds |
hook_no_verify_skips_pre_commit |
Failing hook + --no-verify commits successfully |
hook_pre_merge_aborts |
pre-merge exit 1 aborts merge; refs unchanged |
stray_temp_file_cleaned_on_next_locked_command |
Leftover .astvcs-tmp removed when canonical file exists (unit) |
merge_conflict_still_leaves_refs_and_disk_unchanged_under_lock |
Merge rollback with locking enabled (unit) |
gc_no_unreachable_is_noop, gc_preserves_remote_tracking_blobs, gc_twice_is_idempotent, gc_prune_history_idempotent, gc_preserves_unreachable_states_until_prune_history, gc_prune_history_does_not_remove_reachable_states, gc_prune_history_retains_reachable_manifest_files |
Reachability GC and prune-history unit tests (store/integrity.rs) |
reachable_includes_manifest_id_when_distinct_from_commit |
Reachability records manifest ids separately from commit ids (store/reachability.rs) |
fsck_clean_repository, fsck_repair_fixes_index_inconsistency, fsck_repair_refuses_ambiguous_head, fsck_prune_refs_removes_dangling_ref |
Healthy repo, index repair, ambiguous HEAD refusal, dangling ref prune (unit) |
cli_fsck_clean_repository, cli_fsck_detects_corruption, cli_fsck_repair_fixes_index_inconsistency, cli_fsck_repair_refuses_ambiguous_head, cli_fsck_repair_leaves_missing_blob, cli_fsck_prune_refs_removes_dangling_ref |
fsck clean, corruption detection, repair, and prune-refs (integration) |
checkout_rejects_empty_timeline_manifest |
checkout and fsck fail when a non-root timeline entry's inline manifest was wiped without updating commit metadata |
empty_tree_commit_allows_add_status_and_followup_commit |
Deleting every tracked file, committing, then add/commit new content works on the empty tree |
empty_tree_loads_from_timeline_when_states_dedup_missing |
Verified empty-tree commits load from timeline inline manifest when the states/ dedup file is absent |
commands_discover_repo_from_subdirectory |
add and status from a subdirectory without --repo |
cli_gc_dry_run_and_prune |
gc dry-run reports blobs and history; --prune deletes unreachable blobs (integration) |
path_rename_status_and_diff_integration |
Path rename in status (R old -> new) and diff (RenamePath intent) |
commit_without_identity_fails_with_actionable_error |
commit without configured identity fails (no OS-user fallback) |
identity_set_and_read_roundtrip_via_repo_open |
Repository identity set survives Repo::open |
identity_clear_repo_drops_override |
identity clear removes repo author from config without touching other keys; identity get reports unset repo identity |
identity_recorded_on_commit_merge_and_revert |
Author on timeline entries from commit, merge, and revert |
identity_does_not_change_content_addressed_state_id |
Manifest id remains hash_manifest; commit id is separate |
parallel_branches_identical_content_keep_distinct_log_messages |
Parallel branches with the same tree keep distinct commit ids and log messages |
structured_errors_match_plain_messages_and_kinds |
RepoError.kind and --json stderr; plain message matches string path |
path_rename_exact_reports_rename_intent_in_diff |
Exact path rename intent, not delete+add (unit) |
path_rename_with_edits_reports_rename_with_edits |
AST rename+edit pairing (unit) |
path_rename_merges_with_independent_content_edit |
Rename on one branch + content edit on other merges at renamed path (unit) |
path_rename_conflicts_with_independent_add_at_destination |
Rename vs independent add at destination conflicts (unit) |
conflicting_path_renames_report_conflict |
Both branches rename same path to different destinations (unit) |
move_subtree_and_sibling_payload_edit_merge |
Move + concurrent payload edit merge cleanly (unit) |
identical_mutations_are_not_overlapping |
Byte-identical mutation pairs are not overlapping (unit, merge/mod.rs) |
cross_branch_identical_mutations_merge_cleanly |
Shared identical diff artifacts on both branches do not block merge (unit, merge/mod.rs) |
two_sided_module_eof_function_inserts_both_survive_merge |
Distinct EOF function inserts merge in ours-then-theirs order (unit, merge/mod.rs) |
two_sided_distinct_decorator_inserts_both_survive_merge |
Distinct Python decorator prepends merge with newline separators and three reparsed decorator nodes (unit, merge/mod.rs) |
two_sided_distinct_attribute_inserts_both_survive_merge |
Distinct Rust #[attr] inserts merge with newline separators (unit, merge/mod.rs) |
two_sided_distinct_java_annotation_inserts_both_survive_merge |
Distinct Java marker_annotation prepends merge with space separators (unit, merge/mod.rs) |
first_decorator_wrap_on_bare_function_still_conflicts |
Competing first-time decorated_definition wraps still conflict (unit, merge/mod.rs) |
first_java_modifiers_wrap_still_conflicts |
Competing first-time Java modifiers wraps still conflict (unit, merge/mod.rs) |
identical_reparse_with_duplicate_sibling_node_ids_is_empty |
Three-parameter Rust reparse emits no phantom comma diff (unit, diff/ast_diff.rs) |
disjoint_body_edits_do_not_emit_phantom_comma_inserts |
Body-only edits do not diff phantom parameter commas (unit, diff/ast_diff.rs) |
parameter_count_change_diff_applies_roundtrip |
Adding a parameter diffs and applies to valid source (unit, diff/ast_diff.rs) |
wide_arglist_prepend_and_append_merge_roundtrip |
Prepend + append on the same wide arg list merge to parseable Rust (unit, merge/mod.rs) |
identical_literal_siblings_disjoint_edits_merge |
First vs last identical literal edits merge via occurrence tags and keep comma spacing (unit, merge/mod.rs) |
call_arg_identical_literal_siblings_preserve_spacing |
Merged call-arg duplicate literals keep comma spacing (unit, merge/mod.rs) |
duplicate_if_sibling_disjoint_edits_conflict |
Nested edits inside duplicate sibling trees conflict instead of silently dropping (unit, merge/mod.rs) |
duplicate_block_distinct_field_edits_conflict_not_panic |
Distinct leaves under duplicated blocks conflict instead of unparse panic (unit, merge/mod.rs) |
equal_node_id_pairs_duplicate_siblings_in_order |
Duplicate sibling NodeId alignment pairs in list order (unit, diff/align.rs) |
process_calc_disjoint_edits_merge_valid_rust |
Disjoint body edits in a multi-param function merge to valid, parseable Rust (unit, merge/language_merge_cases.rs) |
merge_disjoint_edits_across_languages_via_repo |
Full repo commit/branch/merge path for every AST frontend (unit, store/repo.rs) |
disjoint_body_edits_merge_across_languages |
Same fixtures through merge_branch (integration) |
moved_function_reports_move_not_delete_insert |
Intra-file reposition avoids delete+insert (unit) |
binary_commit_status_and_diff |
Binary PNG fixture: status modified, diff omits content |
binary_roundtrip_checkout_on_branch |
Byte-for-byte checkout across branches |
binary_merge_add_add_conflict |
Add/add conflict on binary paths |
binary_fsck_clean_after_commit |
NUL-containing binary: fsck clean |
binary_push_clone_roundtrip |
Network clone preserves binary bytes |
binary_reset_hard_roundtrip |
Hard reset restores binary bytes after working-tree edit |
binary_diff_state |
diff --state omits binary content between commits |
symlink_commit_and_checkout |
Symlink target round-trip (all CI platforms) |
executable_mode_commit_and_checkout |
Executable manifest round-trip; Unix restores +x |
symlink_vs_file_merge_conflict |
Merge conflict when one branch has symlink, other regular file |
manifest_hash_regular_backward_compatible |
Regular-mode manifest hash matches legacy string map (unit, store/manifest.rs) |
incremental_status_reuses_unchanged_file_reads |
Second status is incremental and skips tracked loads; touched file alone is re-read (unit, store/repo.rs) |
incremental_scan_reuses_unchanged_paths |
Incremental walk reuses cached path stats (unit, store/walk.rs) |
incremental_scan_finds_new_file_in_changed_dir |
Incremental walk discovers new files when directory metadata changes (unit, store/walk.rs) |
incremental_scan_finds_new_file_in_deep_nested_dir |
Incremental walk finds new files in nested dirs when ancestor metadata is unchanged (unit, store/walk.rs) |
add_all_from_deep_subdirectory_stages_new_file |
add -A . from a nested cwd stages new files after commit (integration) |
verified_detects_content_change_with_unchanged_stat |
Byte digest catches content edits when metadata is stale (unit, store/scan_cache.rs) |
import_git_snapshot_from_subprocess |
import-git reads local git HEAD via subprocess; one commit with import message |
import_git_ignores_stray_untracked_files |
import-git commits only git HEAD paths; stray untracked files on disk are excluded |
import_git_does_not_commit_skipped_binary_stray |
import-git does not commit unrelated binary files left on disk |
parse_ls_tree_line_* |
git ls-tree line parsing (unit, store/git_import.rs) |
merge_disjoint_literal_deltas_are_deterministic_and_parseable |
Proptest: random disjoint calc.rs literal edits merge deterministically and re-parse (proptest, tests/props.rs) |
ast_snapshot_roundtrip_preserves_semantics, blob_store_ast_roundtrip_preserves_semantics |
Proptest: AstGraph snapshot and blob store roundtrips preserve semantics (tests/props.rs) |
merge_commutes_for_disjoint_calc_edits, checkout_roundtrip_leaves_working_tree_unchanged |
Metamorphic merge and checkout oracles (tests/props.rs) |
history_smoke_seeded_repo |
Seeded random repo driver: commit, branch, checkout, merge, gc, repack, fsck (tests/history_smoke.rs) |
history_long_random_repo |
Long random history (#[ignore]; HISTORY_SEED, HISTORY_OPS, tests/history_smoke.rs) |
git_and_astvcs_disjoint_calc_edits_diverge |
Differential: astvcs merges disjoint function-body edits where Git text merge conflicts (tests/diff_git.rs, requires git on PATH) |
git_and_astvcs_same_line_edits_both_conflict, git_and_astvcs_rename_with_body_edit_both_merge |
Differential agreement cases (tests/diff_git.rs) |
merge_driver_resolves_disjoint_structural_edits, merge_driver_conflicts_on_overlapping_literal_edits |
astvcs-merge-driver clean merge and conflict marker write (tests/git_drivers.rs) |
diff_driver_prints_structural_intents, diff_driver_omits_binary_content |
astvcs-diff-driver intent output and binary omission (tests/git_drivers.rs) |
git_invokes_merge_driver_on_disjoint_edits |
End-to-end Git merge with driver registered (tests/git_drivers.rs, requires git on PATH) |
git_invokes_merge_driver_writes_markers_on_conflict |
End-to-end Git conflict leaves markers in the worktree (tests/git_drivers.rs, requires git on PATH) |
Property and history harnesses. tests/props.rs and tests/history_smoke.rs share helpers in tests/common/. Override proptest case count with PROPTEST_CASES (default 64).
Run cargo test, then cargo clippy --all-targets --all-features -- -D warnings. Fixture walkthroughs in examples/README.md mirror several integration tests.