Files
notescraft/scripts/waves/wave10-codex/git-crate.md
T
2026-10-14 00:38:30 -03:00

5.0 KiB

Crates: crates/git (notescraft-git, L0) and then crates/engine (notescraft-engine, L4). TASK: git versioning for a vault. SPEC section 6 items 27-28 (read them), 4.2 (path guard), 3.2 (never crash). Decision recorded: use the git command line through std::process::Command (no libgit2, no shell, no new dependency; the crate may use thiserror and serde, both { workspace = true }). Build in SMALL steps: one file per tool call (<=250 lines), build + clippy after each, every module declared in lib.rs; no unwrap/expect/panic outside tests, no indexing; files under ~300 lines.

PART 1, crate notescraft-git (already registered in the workspace, currently a stub: replace the TODO):

  • Git::open(root: &Path) -> Git (does not touch disk), git_available() -> bool (git --version works), is_repo(&self) -> bool, init(&self) -> Result<()> (runs git init, writes a .gitignore with .notescraft/ and .obsidian/workspace* only if none exists, never overwrites).
  • status(&self) -> Result<Status> parsing git status --porcelain=v2 --branch -z --untracked-files=all into Status { branch: Option<String>, upstream: Option<String>, ahead: u32, behind: u32, changes: Vec<Change> }, Change { path, orig_path: Option<String>, index: ChangeKind, worktree: ChangeKind }, ChangeKind::{None, Modified, Added, Deleted, Renamed, Copied, TypeChanged, Untracked, Conflicted}; paths are vault-relative with /; non-UTF-8 paths are skipped with a counter; cap 20 000 changes (truncated: bool); hidden .notescraft/ entries are excluded.
  • diff(&self, path: &str, staged: bool) -> Result<Diff> parsing git diff [--cached] --no-color --no-ext-diff -U3 -- <path> into Diff { binary: bool, hunks: Vec<Hunk { header: String, lines: Vec<DiffLine { kind: Context|Add|Del, old_no: Option<u32>, new_no: Option<u32>, text: String }> }> }; untracked files diff against /dev/null (git diff --no-index: handle its exit code 1 as success); caps: 2 MiB of output and 20 000 lines (truncated).
  • stage(&self, paths: &[String]), unstage(&self, paths: &[String]), restore(&self, paths: &[String]) (discard worktree changes; for untracked files delete them via git clean -f -- path), commit(&self, message: &str) -> Result<String> (message trimmed, non-empty, max 4 KiB, passed with -F - on stdin; returns the short hash; "nothing to commit" is Error::NothingToCommit), log(&self, limit: usize, path: Option<&str>) -> Result<Vec<Commit>> (git log --format=%H%x1f%an%x1f%aI%x1f%s -z, limit capped at 500, --follow for a single path), show(&self, rev: &str, path: &str) -> Result<String> (file content at a revision, capped 8 MiB; rev must match [0-9a-fA-F]{4,64} or HEAD/HEAD~N), restore_version(&self, rev, path) (writes the old content back through git checkout <rev> -- <path>).
  • SAFETY (mandatory, tested): arguments are never interpolated into a shell; every path goes through a validator (vault-relative, no .., no absolute, no NUL, no leading -, not under .git/, not under .notescraft/ or .obsidian/ for write operations) and follows --; rev is validated as above; the child runs with GIT_TERMINAL_PROMPT=0, GIT_OPTIONAL_LOCKS=0 for read commands, LC_ALL=C, a 30 s timeout (kill on expiry), stdout/stderr size caps; git missing is Error::GitMissing, not a panic; a repository that is not a git repo gives Error::NotARepository for every operation except init.
  • Tests (integration in crates/git/tests using real git in temp dirs; skip with a message if git is missing): init + gitignore, status of new/modified/staged/deleted/renamed/untracked/conflicted files, diff hunks with line numbers, binary diff flag, stage/unstage/restore/commit/log/show round trip, commit message via stdin with quotes and newlines, hostile paths (-rf, --output=/tmp/x, ../x, a/../../x, .git/config), hostile revs (--upload-pack=x, HEAD; rm -rf), huge diff truncation, timeout test with a fake slow git on PATH, not-a-repo errors.

PART 2, engine wiring (crates/engine; add notescraft-git = { workspace = true }): Session owns a Git for its vault root (session.git()); commands registered in register_core with full ParamSpecs and docs: git.status, git.diff (path, optional staged), git.stage/git.unstage/git.restore (paths array), git.commit (message), git.log (optional limit, path), git.show (rev, path), git.restore_version (rev, path), git.init; read commands work in read-only sessions, write commands are disabled in read-only sessions; paths from commands also pass the Session path guard; after a restore/restore_version the Session re-reads the affected notes (apply_event-style re-index) and conflicts with dirty buffers are recorded, not overwritten. A setting git.auto_commit_minutes (0 = off) is stored in AppSettings (validate() clamps 0..=240) but the timer itself is the UI's job. Tests: every command through Registry::run on a temp repo, read-only gating, the registry adversarial_params against every git command, restore re-indexes a note.