Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
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 --versionworks),is_repo(&self) -> bool,init(&self) -> Result<()>(runsgit init, writes a.gitignorewith.notescraft/and.obsidian/workspace*only if none exists, never overwrites).status(&self) -> Result<Status>parsinggit status --porcelain=v2 --branch -z --untracked-files=allintoStatus { 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>parsinggit diff [--cached] --no-color --no-ext-diff -U3 -- <path>intoDiff { 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 viagit 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" isError::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,--followfor a single path),show(&self, rev: &str, path: &str) -> Result<String>(file content at a revision, capped 8 MiB;revmust match[0-9a-fA-F]{4,64}orHEAD/HEAD~N),restore_version(&self, rev, path)(writes the old content back throughgit 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--;revis validated as above; the child runs withGIT_TERMINAL_PROMPT=0,GIT_OPTIONAL_LOCKS=0for read commands,LC_ALL=C, a 30 s timeout (kill on expiry), stdout/stderr size caps;gitmissing isError::GitMissing, not a panic; a repository that is not a git repo givesError::NotARepositoryfor every operation exceptinit. - Tests (integration in crates/git/tests using real
gitin 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 slowgiton 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.