diff --git a/.github/workflows/cloudflare-remote.yml b/.github/workflows/cloudflare-remote.yml new file mode 100644 index 00000000..69ddc20a --- /dev/null +++ b/.github/workflows/cloudflare-remote.yml @@ -0,0 +1,69 @@ +name: Check Graft remote packages + +on: + push: + branches: ["main"] + paths: + - "packages/graft-remote/**" + - "packages/graft-remote-cloudflare/**" + - "packages/graft-remote-hono/**" + - "services/graft-remote-cloudflare/**" + - "package.json" + - "pnpm-lock.yaml" + - "pnpm-workspace.yaml" + - ".github/workflows/cloudflare-remote.yml" + pull_request: + branches: ["main"] + paths: + - "packages/graft-remote/**" + - "packages/graft-remote-cloudflare/**" + - "packages/graft-remote-hono/**" + - "services/graft-remote-cloudflare/**" + - "package.json" + - "pnpm-lock.yaml" + - "pnpm-workspace.yaml" + - ".github/workflows/cloudflare-remote.yml" + workflow_dispatch: + +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + +permissions: + contents: read + +jobs: + check: + runs-on: ubuntu-24.04 + steps: + - uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5 + with: + persist-credentials: false + + - uses: pnpm/action-setup@41ff72655975bd51cab0327fa583b6e92b6d3061 # v4.2.0 + with: + version: 10.14.0 + + - uses: actions/setup-node@395ad3262231945c25e8478fd5baf05154b1d79f # v6 + with: + node-version: 22 + cache: pnpm + cache-dependency-path: pnpm-lock.yaml + + - name: Install dependencies + run: pnpm install --frozen-lockfile + + - name: Typecheck generated bindings and source + run: pnpm check:remote + + - name: Test in the Workers runtime + run: pnpm test:remote + + - name: Validate the publishable packages + run: | + pnpm --filter @eidos.space/graft-remote publish --dry-run --no-git-checks + pnpm --filter @eidos.space/graft-remote-hono publish --dry-run --no-git-checks + pnpm --filter @eidos.space/graft-remote-cloudflare publish --dry-run --no-git-checks + + - name: Validate the deployment bundle + run: pnpm --filter graft-remote-cloudflare-verification exec wrangler deploy --dry-run --outdir /tmp/graft-remote-worker diff --git a/.github/workflows/docs-check.yml b/.github/workflows/docs-check.yml index ab5dcfa3..218f884e 100644 --- a/.github/workflows/docs-check.yml +++ b/.github/workflows/docs-check.yml @@ -30,7 +30,7 @@ jobs: persist-credentials: false - uses: pnpm/action-setup@41ff72655975bd51cab0327fa583b6e92b6d3061 # v4.2.0 with: - version: 10.27 + package_json_file: docs/package.json - uses: actions/setup-node@395ad3262231945c25e8478fd5baf05154b1d79f # v6 with: cache-dependency-path: "docs" diff --git a/.gitignore b/.gitignore index f96b8fd5..2215aadd 100644 --- a/.gitignore +++ b/.gitignore @@ -2,6 +2,7 @@ /mutants.out* /dist/ /distx/ +/node_modules/ **/*.rs.bk *.pdb .env diff --git a/.impeccable.md b/.impeccable.md new file mode 100644 index 00000000..6f9cb97c --- /dev/null +++ b/.impeccable.md @@ -0,0 +1,25 @@ +## Design Context + +### Users + +Developers evaluating or learning Graft who want a zero-install, browser-only sandbox. Their main job is to understand how Graft versions a SQLite-backed worktree by running real commands, inspecting OPFS state, and seeing version-control operations reflected immediately in a GUI. + +### Brand Personality + +Technical, tangible, and trustworthy. The interface should feel like a focused database workbench: direct enough for terminal users, legible enough for someone learning Graft, and calm while exposing low-level state. + +### Aesthetic Direction + +Use a light, Flexoki-adjacent palette that connects naturally to the existing documentation. Build a dense but breathable desktop workbench rather than a marketing dashboard: OPFS file tree at the left, command terminal as the primary surface, and version history/change review at the right. Avoid generic card grids, neon-on-dark developer styling, glass effects, and oversized decorative metrics. Adapt the panes into a useful tabbed workflow on narrow screens instead of hiding functionality. + +### Design Principles + +1. Make cause and effect visible: every command should visibly update files, repository state, and history. +2. Preserve the real mental model: distinguish the OPFS worktree, `.graft` metadata, staged changes, and committed history. +3. Keep the terminal first-class while giving common Graft operations clear GUI controls. +4. Prefer information density with strong hierarchy over decorative containers. +5. Meet WCAG AA contrast, provide keyboard operation, and respect reduced-motion preferences. + +### Component Stack + +Use `@pierre/trees` for the OPFS explorer, `@pierre/diffs` for file/version comparisons, and `@wterm/react` for the terminal surface. diff --git a/Cargo.lock b/Cargo.lock index 9806f9fc..dda562fa 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -981,7 +981,7 @@ dependencies = [ [[package]] name = "graft" -version = "0.6.1" +version = "0.7.0" dependencies = [ "assert_matches", "base64", @@ -1036,7 +1036,7 @@ dependencies = [ [[package]] name = "graft-ext" -version = "0.6.1" +version = "0.7.0" dependencies = [ "config", "graft", @@ -1050,7 +1050,7 @@ dependencies = [ [[package]] name = "graft-sqlite" -version = "0.6.1" +version = "0.7.0" dependencies = [ "bytes", "enum_dispatch", @@ -1062,6 +1062,7 @@ dependencies = [ "serde", "serde_json", "sqlite-plugin", + "tempfile", "thiserror", "tracing", "tryiter", @@ -1070,7 +1071,7 @@ dependencies = [ [[package]] name = "graft-test" -version = "0.6.1" +version = "0.7.0" dependencies = [ "anyhow", "clap", @@ -1093,7 +1094,7 @@ dependencies = [ [[package]] name = "graft-tool" -version = "0.6.1" +version = "0.7.0" dependencies = [ "anyhow", "clap", @@ -1106,7 +1107,7 @@ dependencies = [ [[package]] name = "graft-tracing" -version = "0.6.1" +version = "0.7.0" dependencies = [ "tracing", "tracing-subscriber", diff --git a/DEMO.md b/DEMO.md index 6136049a..a05a3749 100644 --- a/DEMO.md +++ b/DEMO.md @@ -9,16 +9,17 @@ just run sqlite shell --release --client f1 ``` ```sql +-- Low-level volume demo. Repository workflows use the graft CLI. -- volume on s3 -pragma graft_clone = '74ggoCYV4P-2r2cmkXpB2nJ5'; +pragma graft_debug_volume_clone = '74ggoCYV4P-2r2cmkXpB2nJ5'; -- volume on fs -pragma graft_clone = '74ggoDeWBQ-2o7qkWGgrdYDn.'; +pragma graft_debug_volume_clone = '74ggoDeWBQ-2o7qkWGgrdYDn.'; -pragma graft_pull; -pragma graft_push; -pragma graft_info; -pragma graft_status; -pragma graft_audit; +pragma graft_debug_volume_pull; +pragma graft_debug_volume_push; +pragma graft_debug_volume_info; +pragma graft_debug_volume_status; +pragma graft_debug_volume_audit; -- get the total balance of all accounts SELECT SUM(balance) FROM accounts; diff --git a/README.md b/README.md index d1da92d9..ad81cfe7 100644 --- a/README.md +++ b/README.md @@ -100,42 +100,37 @@ graft push Prebuilt CLI and SQLite extension archives are published on the [GitHub releases page](https://github.com/eidos-space/graft/releases). -## Use From SQLite - -The Graft SQLite extension lets applications call repository operations through -SQLite pragmas, which makes the workflow available from Electron, Node.js, -Python, Ruby, Swift, and any runtime with native SQLite support. - -```sql -pragma graft_init; -pragma graft_add = '--all'; -pragma graft_commit = 'Initial version'; -pragma graft_json_status; -pragma graft_json_log; -pragma graft_json_diff = '--rows HEAD'; -pragma graft_json_fetch; -pragma graft_json_pull; -pragma graft_json_push; -``` +## Use With SQLite -Conflict-oriented pragmas expose structured state for app UIs: +The default integration uses ordinary SQLite files. Electron, Node.js, Python, +Ruby, Swift, Rust, and standard SQLite tools can open the worktree database +without a custom VFS. After a transaction commits, stage the database with the +CLI: -```sql -pragma graft_json_conflicts; -pragma graft_json_resolve_conflict = '--theirs assets/model.bin'; -pragma graft_json_resolve_conflict = '--theirs --row docs 42'; -pragma graft_merge_continue = 'Merge remote changes'; -pragma graft_merge_abort; +```bash +sqlite3 data.sqlite "INSERT INTO notes(id, body) VALUES ('1', 'hello')" +graft add data.sqlite +graft commit -m "Add first note" ``` +`graft add` takes a consistent SQLite backup, including committed WAL frames, +then compares it with the staged or committed snapshot. Only changed 4 KiB +pages are written to Graft storage; an unchanged database creates no new +storage commit. + +The SQLite extension remains available for applications that deliberately want +the Graft VFS as a live page-storage data plane. It exposes version and +`graft_debug_*` diagnostics, not repository commands. Use the CLI and its JSON +output for status, staging, history, merge, and sync. + ## Learn More -- [CLI quickstart](./docs/src/content/docs/docs/get-started/cli.mdx) -- [SQLite extension guide](./docs/src/content/docs/docs/get-started/sqlite-extension.mdx) +- [CLI quickstart](./docs/src/content/docs/docs/quickstart/cli.mdx) +- [SQLite extension guide](./docs/src/content/docs/docs/quickstart/sqlite-extension.mdx) - [App state versioning](./docs/src/content/docs/docs/concepts/app-state-versioning.mdx) - [Repository model](./docs/src/content/docs/docs/concepts/repository-model.mdx) - [CLI reference](./docs/src/content/docs/docs/reference/cli.mdx) -- [Pragmas reference](./docs/src/content/docs/docs/reference/pragmas.mdx) +- [VFS pragmas reference](./docs/src/content/docs/docs/reference/pragmas.mdx) - [Configuration reference](./docs/src/content/docs/docs/reference/configuration.mdx) ## Development diff --git a/crates/graft-ext/Cargo.toml b/crates/graft-ext/Cargo.toml index 814a86a3..25aa8b28 100644 --- a/crates/graft-ext/Cargo.toml +++ b/crates/graft-ext/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "graft-ext" -version = "0.6.1" +version = "0.7.0" edition = "2024" authors = { workspace = true } license = { workspace = true } diff --git a/crates/graft-sqlite/Cargo.toml b/crates/graft-sqlite/Cargo.toml index 18cd142b..2b46fd32 100644 --- a/crates/graft-sqlite/Cargo.toml +++ b/crates/graft-sqlite/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "graft-sqlite" -version = "0.6.1" +version = "0.7.0" edition = "2024" authors = { workspace = true } license = { workspace = true } @@ -14,7 +14,7 @@ description = "A SQLite extension which uses Graft to replicate to and from obje workspace = true [dependencies] -graft = { path = "../graft", version = "0.6.1" } +graft = { path = "../graft", version = "0.7.0" } serde = { workspace = true, features = ["derive"] } serde_json = "1.0" @@ -23,8 +23,9 @@ enum_dispatch = { workspace = true } indoc = { workspace = true } itertools = { workspace = true } parking_lot = { workspace = true } -rusqlite = { workspace = true } +rusqlite = { workspace = true, features = ["backup"] } sqlite-plugin = { workspace = true, default-features = false } +tempfile = { workspace = true } thiserror = { workspace = true } tracing = { workspace = true } tryiter = { workspace = true } @@ -36,3 +37,6 @@ default = [] # statically setup in a Rust project register-static = ["sqlite-plugin/static"] bundled-sqlite = ["rusqlite/bundled"] +# Keeps the pre-migration repository/VFS integration suite available without widening the +# production SQLite extension surface. Only graft-test enables this constructor. +test-repository-pragmas = [] diff --git a/crates/graft-sqlite/src/file/vol_file.rs b/crates/graft-sqlite/src/file/vol_file.rs index 6c073a98..a5528935 100644 --- a/crates/graft-sqlite/src/file/vol_file.rs +++ b/crates/graft-sqlite/src/file/vol_file.rs @@ -3,6 +3,7 @@ use std::{ fmt::Debug, hash::{DefaultHasher, Hash, Hasher}, mem, + path::{Path, PathBuf}, sync::{ Arc, atomic::{AtomicBool, AtomicUsize, Ordering}, @@ -123,6 +124,7 @@ pub struct VolFile { repo_runtimes: Arc, workspace: Arc, binding_enabled: bool, + repository_database: Option, tag_bound: bool, workspace_writer_active: bool, @@ -143,6 +145,32 @@ impl Debug for VolFile { } impl VolFile { + pub(crate) fn new_repository_session( + runtime: Runtime, + tag: String, + repository_database: Option, + repo: Option, + repo_runtimes: Arc, + ) -> Result { + let volume = runtime.volume_open(None, None, None)?; + let mut file = Self::new_workspace_session( + runtime, + tag, + volume.vid, + OpenOpts::new( + sqlite_plugin::vars::SQLITE_OPEN_MAIN_DB + | sqlite_plugin::vars::SQLITE_OPEN_READWRITE + | sqlite_plugin::vars::SQLITE_OPEN_CREATE, + ), + Arc::new(Mutex::new(())), + repo, + repo_runtimes, + Arc::new(WorkspaceCoordinator::default()), + ); + file.repository_database = repository_database; + Ok(file) + } + pub fn new( runtime: Runtime, tag: String, @@ -166,7 +194,7 @@ impl VolFile { ) } - pub fn new_workspace_session( + pub(crate) fn new_workspace_session( runtime: Runtime, tag: String, vid: VolumeId, @@ -210,6 +238,7 @@ impl VolFile { repo_runtimes, workspace, binding_enabled, + repository_database: None, tag_bound: binding_enabled, workspace_writer_active: false, reserved, @@ -222,6 +251,18 @@ impl VolFile { &self.runtime } + /// Returns the `SQLite` database selected for row-aware repository operations. + /// + /// VFS-backed files use their open database path. A control-plane-only repository session has + /// no current volume binding, so the CLI passes its optional `--db` path separately. + pub(crate) fn repository_database_path(&self) -> Option<&Path> { + if self.binding_enabled { + Some(Path::new(&self.tag)) + } else { + self.repository_database.as_deref() + } + } + pub fn attach_repo(&mut self, repo: Repository) -> Result<(), ErrCtx> { if !self.is_idle() { return Err(ErrCtx::InvalidVolumeState); diff --git a/crates/graft-sqlite/src/lib.rs b/crates/graft-sqlite/src/lib.rs index d7f1acea..f8ae1c2d 100644 --- a/crates/graft-sqlite/src/lib.rs +++ b/crates/graft-sqlite/src/lib.rs @@ -1,6 +1,7 @@ pub mod file; pub mod json; pub mod pragma; +pub mod repo_service; pub mod row_level_diff; pub mod row_merge; pub mod sql_diff; diff --git a/crates/graft-sqlite/src/pragma.rs b/crates/graft-sqlite/src/pragma.rs index fb6e7707..4960616a 100644 --- a/crates/graft-sqlite/src/pragma.rs +++ b/crates/graft-sqlite/src/pragma.rs @@ -84,13 +84,14 @@ mod repo_sync; mod row_diff; mod row_merge_output; mod spec; +mod sqlite_worktree; mod volume_output; use self::{ jobs::*, json::*, output_types::*, parse::*, repo_checkout::*, repo_conflicts::*, repo_core::*, repo_diff::*, repo_history::*, repo_merge::*, repo_output::*, repo_paths::*, repo_refs::*, repo_remote_output::*, repo_snapshot::*, repo_staging::*, repo_switch::*, repo_sync::*, - row_diff::*, row_merge_output::*, spec::*, volume_output::*, + row_diff::*, row_merge_output::*, spec::*, sqlite_worktree::*, volume_output::*, }; const SQLITE_DATABASE_MAGIC: &[u8; 16] = b"SQLite format 3\0"; @@ -129,7 +130,7 @@ impl<'a> PragmaExt<'a> for Pragma<'a> { } } -pub(crate) enum GraftPragma { +pub(crate) enum GraftCommand { /// `pragma graft_debug_volume_list;` VolumeList, @@ -659,55 +660,53 @@ pub(crate) enum GraftPragma { VolumeSetMessage { message: String }, } -impl TryFrom<&Pragma<'_>> for GraftPragma { - type Error = PragmaErr; - - fn try_from(p: &Pragma<'_>) -> Result { +impl GraftCommand { + pub(crate) fn parse(p: &Pragma<'_>) -> Result { if let Some((prefix, suffix)) = p.name.split_once("_") && prefix == "graft" { return match suffix { - "debug_volume_list" => Ok(GraftPragma::VolumeList), - "debug_volume_json_list" => Ok(GraftPragma::VolumeJsonList), - "tags" => Ok(GraftPragma::Tags), - "json_tags" => Ok(GraftPragma::JsonTags { mode: parse_json_tags_arg(p.arg)? }), - "debug_volume_tags" => Ok(GraftPragma::VolumeTags), + "debug_volume_list" => Ok(GraftCommand::VolumeList), + "debug_volume_json_list" => Ok(GraftCommand::VolumeJsonList), + "tags" => Ok(GraftCommand::Tags), + "json_tags" => Ok(GraftCommand::JsonTags { mode: parse_json_tags_arg(p.arg)? }), + "debug_volume_tags" => Ok(GraftCommand::VolumeTags), "debug_volume_clone" => { let remote = p.arg.map(parse_or_fail).transpose()?; - Ok(GraftPragma::VolumeClone { remote }) + Ok(GraftCommand::VolumeClone { remote }) } - "debug_volume_fork" => Ok(GraftPragma::VolumeFork), + "debug_volume_fork" => Ok(GraftCommand::VolumeFork), "checkout" => { let arg = p.require_arg()?; let spec = parse_repo_checkout_arg(arg)?; - Ok(GraftPragma::RepoCheckout { spec }) + Ok(GraftCommand::RepoCheckout { spec }) } "json_checkout" => { let arg = p.require_arg()?; let spec = parse_repo_checkout_arg(arg)?; - Ok(GraftPragma::JsonRepoCheckout { spec }) + Ok(GraftCommand::JsonRepoCheckout { spec }) } "restore" => { let arg = p.require_arg()?; let spec = parse_repo_restore_arg(arg)?; - Ok(GraftPragma::Restore { spec }) + Ok(GraftCommand::Restore { spec }) } "json_restore" => { let arg = p.require_arg()?; let spec = parse_repo_restore_arg(arg)?; - Ok(GraftPragma::JsonRestore { spec }) + Ok(GraftCommand::JsonRestore { spec }) } "export" => { let arg = p.require_arg()?; let spec = parse_repo_export_arg(arg)?; - Ok(GraftPragma::Export { spec }) + Ok(GraftCommand::Export { spec }) } "json_export" => { let arg = p.require_arg()?; let spec = parse_repo_export_arg(arg)?; - Ok(GraftPragma::JsonExport { spec }) + Ok(GraftCommand::JsonExport { spec }) } - "debug_volume_new" => Ok(GraftPragma::VolumeSwitch { + "debug_volume_new" => Ok(GraftCommand::VolumeSwitch { vid: VolumeId::random(), local: None, remote: None, @@ -719,180 +718,180 @@ impl TryFrom<&Pragma<'_>> for GraftPragma { "argument must be in the form: `local_vid[:local[:remote]]`", )); } - Ok(GraftPragma::VolumeSwitch { + Ok(GraftCommand::VolumeSwitch { vid: parse_or_fail(parts[0])?, local: parse_optional(parts.get(1))?, remote: parse_optional(parts.get(2))?, }) } - "debug_volume_info" => Ok(GraftPragma::VolumeInfo), - "status" => Ok(GraftPragma::Status { spec: parse_status_arg(p.arg)? }), - "debug_volume_status" => Ok(GraftPragma::VolumeStatus), - "init" => Ok(GraftPragma::RepoInit { spec: parse_repo_init_arg(p.arg)? }), - "json_init" => Ok(GraftPragma::JsonRepoInit { spec: parse_repo_init_arg(p.arg)? }), + "debug_volume_info" => Ok(GraftCommand::VolumeInfo), + "status" => Ok(GraftCommand::Status { spec: parse_status_arg(p.arg)? }), + "debug_volume_status" => Ok(GraftCommand::VolumeStatus), + "init" => Ok(GraftCommand::RepoInit { spec: parse_repo_init_arg(p.arg)? }), + "json_init" => Ok(GraftCommand::JsonRepoInit { spec: parse_repo_init_arg(p.arg)? }), "clone" => { let spec = parse_repo_clone_arg(p.require_arg()?)?; - Ok(GraftPragma::RepoClone { spec }) + Ok(GraftCommand::RepoClone { spec }) } "json_clone" => { let spec = parse_repo_clone_arg(p.require_arg()?)?; - Ok(GraftPragma::JsonRepoClone { spec }) - } - "json_status" => Ok(GraftPragma::JsonStatus { spec: parse_status_arg(p.arg)? }), - "add" => Ok(GraftPragma::Add { spec: parse_repo_add_arg(p.arg)? }), - "json_add" => Ok(GraftPragma::JsonAdd { spec: parse_repo_add_arg(p.arg)? }), - "rm" => Ok(GraftPragma::Remove { spec: parse_repo_remove_arg(p.arg)? }), - "json_rm" => Ok(GraftPragma::JsonRemove { spec: parse_repo_remove_arg(p.arg)? }), - "commit" => Ok(GraftPragma::Commit { message: p.require_arg()?.to_string() }), + Ok(GraftCommand::JsonRepoClone { spec }) + } + "json_status" => Ok(GraftCommand::JsonStatus { spec: parse_status_arg(p.arg)? }), + "add" => Ok(GraftCommand::Add { spec: parse_repo_add_arg(p.arg)? }), + "json_add" => Ok(GraftCommand::JsonAdd { spec: parse_repo_add_arg(p.arg)? }), + "rm" => Ok(GraftCommand::Remove { spec: parse_repo_remove_arg(p.arg)? }), + "json_rm" => Ok(GraftCommand::JsonRemove { spec: parse_repo_remove_arg(p.arg)? }), + "commit" => Ok(GraftCommand::Commit { message: p.require_arg()?.to_string() }), "json_commit" => { - Ok(GraftPragma::JsonCommit { message: p.require_arg()?.to_string() }) + Ok(GraftCommand::JsonCommit { message: p.require_arg()?.to_string() }) } - "branch" => Ok(GraftPragma::Branch { mode: parse_branch_list_mode(p.arg)? }), + "branch" => Ok(GraftCommand::Branch { mode: parse_branch_list_mode(p.arg)? }), "json_branch" => { - Ok(GraftPragma::JsonBranch { mode: parse_branch_list_mode(p.arg)? }) + Ok(GraftCommand::JsonBranch { mode: parse_branch_list_mode(p.arg)? }) } "branch_create" => { let (name, start_point) = parse_branch_create_arg(p.require_arg()?)?; - Ok(GraftPragma::BranchCreate { name, start_point }) + Ok(GraftCommand::BranchCreate { name, start_point }) } "json_branch_create" => { let (name, start_point) = parse_branch_create_arg(p.require_arg()?)?; - Ok(GraftPragma::JsonBranchCreate { name, start_point }) + Ok(GraftCommand::JsonBranchCreate { name, start_point }) } "branch_delete" => { let (name, force) = parse_branch_delete_arg(p.require_arg()?)?; - Ok(GraftPragma::BranchDelete { name, force }) + Ok(GraftCommand::BranchDelete { name, force }) } "json_branch_delete" => { let (name, force) = parse_branch_delete_arg(p.require_arg()?)?; - Ok(GraftPragma::JsonBranchDelete { name, force }) + Ok(GraftCommand::JsonBranchDelete { name, force }) } "branch_rename" => { let (old, new, force) = parse_branch_rename_arg(p.require_arg()?)?; - Ok(GraftPragma::BranchRename { old, new, force }) + Ok(GraftCommand::BranchRename { old, new, force }) } "json_branch_rename" => { let (old, new, force) = parse_branch_rename_arg(p.require_arg()?)?; - Ok(GraftPragma::JsonBranchRename { old, new, force }) + Ok(GraftCommand::JsonBranchRename { old, new, force }) } "branch_upstream" => { let (branch, remote, remote_branch) = parse_branch_upstream_arg(p.require_arg()?)?; - Ok(GraftPragma::BranchUpstream { branch, remote, remote_branch }) + Ok(GraftCommand::BranchUpstream { branch, remote, remote_branch }) } "json_branch_upstream" => { let (branch, remote, remote_branch) = parse_branch_upstream_arg(p.require_arg()?)?; - Ok(GraftPragma::JsonBranchUpstream { branch, remote, remote_branch }) + Ok(GraftCommand::JsonBranchUpstream { branch, remote, remote_branch }) } "branch_unset_upstream" => { - Ok(GraftPragma::BranchUnsetUpstream { branch: p.arg.map(str::to_string) }) + Ok(GraftCommand::BranchUnsetUpstream { branch: p.arg.map(str::to_string) }) } "json_branch_unset_upstream" => { - Ok(GraftPragma::JsonBranchUnsetUpstream { branch: p.arg.map(str::to_string) }) + Ok(GraftCommand::JsonBranchUnsetUpstream { branch: p.arg.map(str::to_string) }) } "tag_create" => { let (name, target, message) = parse_tag_create_arg(p.require_arg()?)?; - Ok(GraftPragma::TagCreate { name, target, message }) + Ok(GraftCommand::TagCreate { name, target, message }) } "json_tag_create" => { let (name, target, message) = parse_tag_create_arg(p.require_arg()?)?; - Ok(GraftPragma::JsonTagCreate { name, target, message }) + Ok(GraftCommand::JsonTagCreate { name, target, message }) } - "tag_delete" => Ok(GraftPragma::TagDelete { name: p.require_arg()?.to_string() }), + "tag_delete" => Ok(GraftCommand::TagDelete { name: p.require_arg()?.to_string() }), "json_tag_delete" => { - Ok(GraftPragma::JsonTagDelete { name: p.require_arg()?.to_string() }) + Ok(GraftCommand::JsonTagDelete { name: p.require_arg()?.to_string() }) } "switch_branch" => { let (name, force) = parse_switch_branch_arg(p.require_arg()?)?; - Ok(GraftPragma::SwitchBranch { name, force }) + Ok(GraftCommand::SwitchBranch { name, force }) } "json_switch_branch" => { let (name, force) = parse_switch_branch_arg(p.require_arg()?)?; - Ok(GraftPragma::JsonSwitchBranch { name, force }) + Ok(GraftCommand::JsonSwitchBranch { name, force }) } "switch_create" => { let (name, start_point, force) = parse_switch_create_arg(p.require_arg()?)?; - Ok(GraftPragma::SwitchCreate { name, start_point, force }) + Ok(GraftCommand::SwitchCreate { name, start_point, force }) } "json_switch_create" => { let (name, start_point, force) = parse_switch_create_arg(p.require_arg()?)?; - Ok(GraftPragma::JsonSwitchCreate { name, start_point, force }) + Ok(GraftCommand::JsonSwitchCreate { name, start_point, force }) } - "merge" => Ok(GraftPragma::Merge { rev: p.require_arg()?.to_string() }), - "json_merge" => Ok(GraftPragma::JsonMerge { rev: p.require_arg()?.to_string() }), - "merge_abort" => Ok(GraftPragma::MergeAbort), - "json_merge_abort" => Ok(GraftPragma::JsonMergeAbort), + "merge" => Ok(GraftCommand::Merge { rev: p.require_arg()?.to_string() }), + "json_merge" => Ok(GraftCommand::JsonMerge { rev: p.require_arg()?.to_string() }), + "merge_abort" => Ok(GraftCommand::MergeAbort), + "json_merge_abort" => Ok(GraftCommand::JsonMergeAbort), "merge_continue" => { - Ok(GraftPragma::MergeContinue { message: p.require_arg()?.to_string() }) + Ok(GraftCommand::MergeContinue { message: p.require_arg()?.to_string() }) } "json_merge_continue" => { - Ok(GraftPragma::JsonMergeContinue { message: p.require_arg()?.to_string() }) + Ok(GraftCommand::JsonMergeContinue { message: p.require_arg()?.to_string() }) } - "conflicts" => Ok(GraftPragma::Conflicts), - "json_conflicts" => Ok(GraftPragma::JsonConflicts), - "resolve" => Ok(GraftPragma::Resolve { + "conflicts" => Ok(GraftCommand::Conflicts), + "json_conflicts" => Ok(GraftCommand::JsonConflicts), + "resolve" => Ok(GraftCommand::Resolve { spec: parse_repo_resolve_arg(p.require_arg()?)?, }), - "json_resolve_conflict" => Ok(GraftPragma::JsonResolveConflict { + "json_resolve_conflict" => Ok(GraftCommand::JsonResolveConflict { spec: parse_repo_resolve_arg(p.require_arg()?)?, }), "remote_add" => { let (name, config) = parse_remote_add(p.require_arg()?)?; - Ok(GraftPragma::RemoteAdd { name, config }) + Ok(GraftCommand::RemoteAdd { name, config }) } "json_remote_add" => { let (name, config) = parse_remote_add(p.require_arg()?)?; - Ok(GraftPragma::JsonRemoteAdd { name, config }) + Ok(GraftCommand::JsonRemoteAdd { name, config }) } "remote_remove" => { - Ok(GraftPragma::RemoteRemove { name: p.require_arg()?.to_string() }) + Ok(GraftCommand::RemoteRemove { name: p.require_arg()?.to_string() }) } "json_remote_remove" => { - Ok(GraftPragma::JsonRemoteRemove { name: p.require_arg()?.to_string() }) + Ok(GraftCommand::JsonRemoteRemove { name: p.require_arg()?.to_string() }) } "remote_rename" => { let (old, new) = parse_remote_rename(p.require_arg()?)?; - Ok(GraftPragma::RemoteRename { old, new }) + Ok(GraftCommand::RemoteRename { old, new }) } "json_remote_rename" => { let (old, new) = parse_remote_rename(p.require_arg()?)?; - Ok(GraftPragma::JsonRemoteRename { old, new }) + Ok(GraftCommand::JsonRemoteRename { old, new }) } "remote_get_url" => { - Ok(GraftPragma::RemoteGetUrl { name: p.require_arg()?.to_string() }) + Ok(GraftCommand::RemoteGetUrl { name: p.require_arg()?.to_string() }) } "json_remote_get_url" => { - Ok(GraftPragma::JsonRemoteGetUrl { name: p.require_arg()?.to_string() }) + Ok(GraftCommand::JsonRemoteGetUrl { name: p.require_arg()?.to_string() }) } "remote_set_url" => { let (name, config) = parse_remote_add(p.require_arg()?)?; - Ok(GraftPragma::RemoteSetUrl { name, config }) + Ok(GraftCommand::RemoteSetUrl { name, config }) } "json_remote_set_url" => { let (name, config) = parse_remote_add(p.require_arg()?)?; - Ok(GraftPragma::JsonRemoteSetUrl { name, config }) + Ok(GraftCommand::JsonRemoteSetUrl { name, config }) } "remote_prune" => { - Ok(GraftPragma::RemotePrune { name: p.require_arg()?.to_string() }) + Ok(GraftCommand::RemotePrune { name: p.require_arg()?.to_string() }) } "json_remote_prune" => { - Ok(GraftPragma::JsonRemotePrune { name: p.require_arg()?.to_string() }) + Ok(GraftCommand::JsonRemotePrune { name: p.require_arg()?.to_string() }) } - "ls_remote" => Ok(GraftPragma::LsRemote { name: p.require_arg()?.to_string() }), + "ls_remote" => Ok(GraftCommand::LsRemote { name: p.require_arg()?.to_string() }), "json_ls_remote" => { - Ok(GraftPragma::JsonLsRemote { name: p.require_arg()?.to_string() }) + Ok(GraftCommand::JsonLsRemote { name: p.require_arg()?.to_string() }) } - "remotes" => Ok(GraftPragma::Remotes), - "json_remotes" => Ok(GraftPragma::JsonRemotes), - "debug_volume_snapshot" => Ok(GraftPragma::VolumeSnapshot), + "remotes" => Ok(GraftCommand::Remotes), + "json_remotes" => Ok(GraftCommand::JsonRemotes), + "debug_volume_snapshot" => Ok(GraftCommand::VolumeSnapshot), "fetch" => { let arg = parse_remote_branch_arg(p.arg)?; if arg.force { return Err(pragma_fail("fetch does not support --force")); } let RemoteBranchArg { remote, branch, refspec, all, .. } = arg; - Ok(GraftPragma::Fetch { remote, branch, refspec, all }) + Ok(GraftCommand::Fetch { remote, branch, refspec, all }) } "json_fetch" => { let arg = parse_remote_branch_arg(p.arg)?; @@ -900,7 +899,7 @@ impl TryFrom<&Pragma<'_>> for GraftPragma { return Err(pragma_fail("json_fetch does not support --force")); } let RemoteBranchArg { remote, branch, refspec, all, .. } = arg; - Ok(GraftPragma::JsonFetch { remote, branch, refspec, all }) + Ok(GraftCommand::JsonFetch { remote, branch, refspec, all }) } "fetch_async" => { let arg = parse_remote_branch_arg(p.arg)?; @@ -908,7 +907,7 @@ impl TryFrom<&Pragma<'_>> for GraftPragma { return Err(pragma_fail("fetch_async does not support --force")); } let RemoteBranchArg { remote, branch, refspec, all, .. } = arg; - Ok(GraftPragma::FetchAsync { remote, branch, refspec, all }) + Ok(GraftCommand::FetchAsync { remote, branch, refspec, all }) } "json_fetch_async" => { let (arg, mode) = parse_json_fetch_async_arg(p.arg)?; @@ -916,15 +915,15 @@ impl TryFrom<&Pragma<'_>> for GraftPragma { return Err(pragma_fail("json_fetch_async does not support --force")); } let RemoteBranchArg { remote, branch, refspec, all, .. } = arg; - Ok(GraftPragma::JsonFetchAsync { remote, branch, refspec, all, mode }) + Ok(GraftCommand::JsonFetchAsync { remote, branch, refspec, all, mode }) } - "job_status" => Ok(GraftPragma::JobStatus { id: p.require_arg()?.to_string() }), + "job_status" => Ok(GraftCommand::JobStatus { id: p.require_arg()?.to_string() }), "json_job_status" => { - Ok(GraftPragma::JsonJobStatus { id: p.require_arg()?.to_string() }) + Ok(GraftCommand::JsonJobStatus { id: p.require_arg()?.to_string() }) } - "job_result" => Ok(GraftPragma::JobResult { id: p.require_arg()?.to_string() }), + "job_result" => Ok(GraftCommand::JobResult { id: p.require_arg()?.to_string() }), "json_job_result" => { - Ok(GraftPragma::JsonJobResult { id: p.require_arg()?.to_string() }) + Ok(GraftCommand::JsonJobResult { id: p.require_arg()?.to_string() }) } "pull" => { let arg = parse_remote_branch_arg(p.arg)?; @@ -932,7 +931,7 @@ impl TryFrom<&Pragma<'_>> for GraftPragma { return Err(pragma_fail("pull does not support --force")); } let RemoteBranchArg { remote, branch, refspec, all, .. } = arg; - Ok(GraftPragma::Pull { remote, branch, refspec, all }) + Ok(GraftCommand::Pull { remote, branch, refspec, all }) } "json_pull" => { let arg = parse_remote_branch_arg(p.arg)?; @@ -940,171 +939,262 @@ impl TryFrom<&Pragma<'_>> for GraftPragma { return Err(pragma_fail("json_pull does not support --force")); } let RemoteBranchArg { remote, branch, refspec, all, .. } = arg; - Ok(GraftPragma::JsonPull { remote, branch, refspec, all }) + Ok(GraftCommand::JsonPull { remote, branch, refspec, all }) } "push" => { let RemoteBranchArg { remote, branch, refspec, all, force } = parse_remote_branch_arg(p.arg)?; - Ok(GraftPragma::Push { remote, branch, refspec, all, force }) + Ok(GraftCommand::Push { remote, branch, refspec, all, force }) } "json_push" => { let RemoteBranchArg { remote, branch, refspec, all, force } = parse_remote_branch_arg(p.arg)?; - Ok(GraftPragma::JsonPush { remote, branch, refspec, all, force }) - } - "debug_volume_fetch" => Ok(GraftPragma::VolumeFetch), - "debug_volume_pull" => Ok(GraftPragma::VolumePull), - "debug_volume_push" => Ok(GraftPragma::VolumePush), - "debug_volume_audit" => Ok(GraftPragma::VolumeAudit), - "debug_volume_json_audit" => Ok(GraftPragma::VolumeJsonAudit), - "audit" => Ok(GraftPragma::RepoAudit { spec: parse_repo_audit_arg(p.arg)? }), + Ok(GraftCommand::JsonPush { remote, branch, refspec, all, force }) + } + "debug_volume_fetch" => Ok(GraftCommand::VolumeFetch), + "debug_volume_pull" => Ok(GraftCommand::VolumePull), + "debug_volume_push" => Ok(GraftCommand::VolumePush), + "debug_volume_audit" => Ok(GraftCommand::VolumeAudit), + "debug_volume_json_audit" => Ok(GraftCommand::VolumeJsonAudit), + "audit" => Ok(GraftCommand::RepoAudit { spec: parse_repo_audit_arg(p.arg)? }), "json_audit" => { - Ok(GraftPragma::JsonRepoAudit { spec: parse_repo_audit_arg(p.arg)? }) + Ok(GraftCommand::JsonRepoAudit { spec: parse_repo_audit_arg(p.arg)? }) } "lfs_fetch" | "payload_fetch" => { - Ok(GraftPragma::LargeFileFetch { spec: parse_lfs_fetch_arg(p.arg)? }) + Ok(GraftCommand::LargeFileFetch { spec: parse_lfs_fetch_arg(p.arg)? }) } - "json_lfs_fetch" => Ok(GraftPragma::JsonLargeFileFetch { + "json_lfs_fetch" => Ok(GraftCommand::JsonLargeFileFetch { spec: parse_lfs_fetch_arg(p.arg)?, operation: "lfs_fetch", }), - "json_payload_fetch" => Ok(GraftPragma::JsonLargeFileFetch { + "json_payload_fetch" => Ok(GraftCommand::JsonLargeFileFetch { spec: parse_lfs_fetch_arg(p.arg)?, operation: "payload_fetch", }), "lfs_status" | "payload_status" => { - Ok(GraftPragma::LargeFileStatus { spec: parse_lfs_status_arg(p.arg)? }) + Ok(GraftCommand::LargeFileStatus { spec: parse_lfs_status_arg(p.arg)? }) } - "json_lfs_status" => Ok(GraftPragma::JsonLargeFileStatus { + "json_lfs_status" => Ok(GraftCommand::JsonLargeFileStatus { spec: parse_lfs_status_arg(p.arg)?, operation: "lfs_status", }), - "json_payload_status" => Ok(GraftPragma::JsonLargeFileStatus { + "json_payload_status" => Ok(GraftCommand::JsonLargeFileStatus { spec: parse_lfs_status_arg(p.arg)?, operation: "payload_status", }), "lfs_prune" | "payload_prune" => { - Ok(GraftPragma::LargeFilePrune { spec: parse_lfs_prune_arg(p.arg)? }) + Ok(GraftCommand::LargeFilePrune { spec: parse_lfs_prune_arg(p.arg)? }) } - "json_lfs_prune" => Ok(GraftPragma::JsonLargeFilePrune { + "json_lfs_prune" => Ok(GraftCommand::JsonLargeFilePrune { spec: parse_lfs_prune_arg(p.arg)?, operation: "lfs_prune", }), - "json_payload_prune" => Ok(GraftPragma::JsonLargeFilePrune { + "json_payload_prune" => Ok(GraftCommand::JsonLargeFilePrune { spec: parse_lfs_prune_arg(p.arg)?, operation: "payload_prune", }), - "gc" => Ok(GraftPragma::StorageGc { spec: parse_storage_gc_arg(p.arg)? }), - "json_gc" => Ok(GraftPragma::JsonStorageGc { spec: parse_storage_gc_arg(p.arg)? }), - "ls_files" => Ok(GraftPragma::LsFiles { spec: parse_ls_files_arg(p.arg)? }), + "gc" => Ok(GraftCommand::StorageGc { spec: parse_storage_gc_arg(p.arg)? }), + "json_gc" => Ok(GraftCommand::JsonStorageGc { spec: parse_storage_gc_arg(p.arg)? }), + "ls_files" => Ok(GraftCommand::LsFiles { spec: parse_ls_files_arg(p.arg)? }), "json_ls_files" => { - Ok(GraftPragma::JsonLsFiles { spec: parse_ls_files_arg(p.arg)? }) + Ok(GraftCommand::JsonLsFiles { spec: parse_ls_files_arg(p.arg)? }) } - "config_get" => Ok(GraftPragma::ConfigGet { key: p.require_arg()?.to_string() }), + "config_get" => Ok(GraftCommand::ConfigGet { key: p.require_arg()?.to_string() }), "json_config_get" => { - Ok(GraftPragma::JsonConfigGet { key: p.require_arg()?.to_string() }) + Ok(GraftCommand::JsonConfigGet { key: p.require_arg()?.to_string() }) } - "config_list" => Ok(GraftPragma::ConfigList), + "config_list" => Ok(GraftCommand::ConfigList), "json_config_list" => { - Ok(GraftPragma::JsonConfigList { mode: parse_json_config_list_arg(p.arg)? }) + Ok(GraftCommand::JsonConfigList { mode: parse_json_config_list_arg(p.arg)? }) } "config_set" => { let (key, value) = parse_repo_config_set_arg(p.require_arg()?)?; - Ok(GraftPragma::ConfigSet { key, value }) + Ok(GraftCommand::ConfigSet { key, value }) } "json_config_set" => { let (key, value) = parse_repo_config_set_arg(p.require_arg()?)?; - Ok(GraftPragma::JsonConfigSet { key, value }) + Ok(GraftCommand::JsonConfigSet { key, value }) } "config_unset" => { - Ok(GraftPragma::ConfigUnset { key: p.require_arg()?.to_string() }) + Ok(GraftCommand::ConfigUnset { key: p.require_arg()?.to_string() }) } "json_config_unset" => { - Ok(GraftPragma::JsonConfigUnset { key: p.require_arg()?.to_string() }) + Ok(GraftCommand::JsonConfigUnset { key: p.require_arg()?.to_string() }) } - "debug_volume_hydrate" => Ok(GraftPragma::VolumeHydrate), - "version" => Ok(GraftPragma::Version), + "debug_volume_hydrate" => Ok(GraftCommand::VolumeHydrate), + "version" => Ok(GraftCommand::Version), "debug_volume_import" => { let _ = p.require_arg()?; - Ok(GraftPragma::VolumeImport) + Ok(GraftCommand::VolumeImport) } "debug_volume_export" => { - Ok(GraftPragma::VolumeExport(PathBuf::from(p.require_arg()?))) + Ok(GraftCommand::VolumeExport(PathBuf::from(p.require_arg()?))) } - "debug_volume_dump_header" => Ok(GraftPragma::VolumeDumpSqliteHeader), + "debug_volume_dump_header" => Ok(GraftCommand::VolumeDumpSqliteHeader), "debug_volume_dump_commit" => { - Ok(GraftPragma::VolumeDumpCommit { logref: parse_or_fail(p.require_arg()?)? }) + Ok(GraftCommand::VolumeDumpCommit { logref: parse_or_fail(p.require_arg()?)? }) } - "debug_log_lsn" => Ok(GraftPragma::DebugLogLsn), + "debug_log_lsn" => Ok(GraftCommand::DebugLogLsn), "debug_show_lsn" => { - Ok(GraftPragma::DebugShowLsn { logref: parse_or_fail(p.require_arg()?)? }) + Ok(GraftCommand::DebugShowLsn { logref: parse_or_fail(p.require_arg()?)? }) } "debug_diff_lsn" => { let (from, to) = parse_debug_diff_lsn_arg(p.require_arg()?)?; - Ok(GraftPragma::DebugDiffLsn { from, to }) + Ok(GraftCommand::DebugDiffLsn { from, to }) } - "log" => Ok(GraftPragma::Log), + "log" => Ok(GraftCommand::Log), "debug_volume_checkout_lsn" => { - Ok(GraftPragma::VolumeCheckoutLsn { lsn: parse_or_fail(p.require_arg()?)? }) + Ok(GraftCommand::VolumeCheckoutLsn { lsn: parse_or_fail(p.require_arg()?)? }) } "debug_volume_reset_to" => { - Ok(GraftPragma::VolumeResetTo { lsn: parse_or_fail(p.require_arg()?)? }) + Ok(GraftCommand::VolumeResetTo { lsn: parse_or_fail(p.require_arg()?)? }) } "reset" => { let (mode, rev) = parse_repo_reset_arg(p.require_arg()?)?; - Ok(GraftPragma::Reset { rev, mode }) + Ok(GraftCommand::Reset { rev, mode }) } "json_reset" => { let (mode, rev) = parse_repo_reset_arg(p.require_arg()?)?; - Ok(GraftPragma::JsonReset { rev, mode }) + Ok(GraftCommand::JsonReset { rev, mode }) } "diff" => { let spec = parse_repo_diff_arg(p.arg)?; - Ok(GraftPragma::RepoDiff { spec }) + Ok(GraftCommand::RepoDiff { spec }) } "debug_volume_diff" => { let (from, to, mode) = parse_volume_diff_arg(p.require_arg()?)?; - Ok(GraftPragma::VolumeDiff { from, to, mode }) + Ok(GraftCommand::VolumeDiff { from, to, mode }) } - "show" => Ok(GraftPragma::Show { target: p.require_arg()?.to_string() }), - "json_log" => Ok(GraftPragma::JsonLog { spec: parse_json_log_arg(p.arg)? }), + "show" => Ok(GraftCommand::Show { target: p.require_arg()?.to_string() }), + "json_log" => Ok(GraftCommand::JsonLog { spec: parse_json_log_arg(p.arg)? }), "json_diff" => { let spec = parse_repo_diff_arg(p.arg)?; - Ok(GraftPragma::JsonRepoDiff { spec }) + Ok(GraftCommand::JsonRepoDiff { spec }) } "debug_volume_json_diff" => { let (from, to, mode) = parse_volume_diff_arg(p.require_arg()?)?; - Ok(GraftPragma::VolumeJsonDiff { from, to, mode }) + Ok(GraftCommand::VolumeJsonDiff { from, to, mode }) } - "json_show" => Ok(GraftPragma::JsonShow { target: p.require_arg()?.to_string() }), - "debug_volume_json_info" => Ok(GraftPragma::VolumeJsonInfo), + "json_show" => Ok(GraftCommand::JsonShow { target: p.require_arg()?.to_string() }), + "debug_volume_json_info" => Ok(GraftCommand::VolumeJsonInfo), "debug_volume_table_log" => { - Ok(GraftPragma::VolumeTableLog { table: p.require_arg()?.to_string() }) + Ok(GraftCommand::VolumeTableLog { table: p.require_arg()?.to_string() }) } "debug_volume_json_table_log" => { - Ok(GraftPragma::VolumeJsonTableLog { table: p.require_arg()?.to_string() }) + Ok(GraftCommand::VolumeJsonTableLog { table: p.require_arg()?.to_string() }) } "debug_volume_set_message" => { - Ok(GraftPragma::VolumeSetMessage { message: p.require_arg()?.to_string() }) + Ok(GraftCommand::VolumeSetMessage { message: p.require_arg()?.to_string() }) } _ => Err(pragma_fail(format!("invalid graft pragma `{}`", p.name))), }; } Err(PragmaErr::NotFound) } + + pub(crate) fn parse_repository(name: &str, argument: Option<&str>) -> Result { + let full_name = format!("graft_{name}"); + let input = Pragma { name: &full_name, arg: argument }; + let command = Self::parse(&input).map_err(|error| match error { + PragmaErr::NotFound => ErrCtx::UnknownPragma, + PragmaErr::Fail(_, message) => ErrCtx::PragmaErr( + message + .unwrap_or_else(|| "invalid repository command".to_string()) + .into(), + ), + })?; + if command.is_vfs_pragma() { + return Err(ErrCtx::PragmaErr( + format!("`{name}` is a VFS command, not a repository command").into(), + )); + } + Ok(command) + } + + pub(crate) fn is_vfs_pragma(&self) -> bool { + matches!( + self, + Self::VolumeList + | Self::VolumeJsonList + | Self::VolumeTags + | Self::VolumeSwitch { .. } + | Self::VolumeClone { .. } + | Self::VolumeFork + | Self::VolumeInfo + | Self::VolumeStatus + | Self::VolumeSnapshot + | Self::VolumeFetch + | Self::VolumePull + | Self::VolumePush + | Self::VolumeAudit + | Self::VolumeJsonAudit + | Self::VolumeHydrate + | Self::Version + | Self::VolumeImport + | Self::VolumeExport(_) + | Self::VolumeDumpSqliteHeader + | Self::VolumeDumpCommit { .. } + | Self::DebugLogLsn + | Self::DebugShowLsn { .. } + | Self::DebugDiffLsn { .. } + | Self::VolumeCheckoutLsn { .. } + | Self::VolumeResetTo { .. } + | Self::VolumeDiff { .. } + | Self::VolumeJsonDiff { .. } + | Self::VolumeJsonInfo + | Self::VolumeTableLog { .. } + | Self::VolumeJsonTableLog { .. } + | Self::VolumeSetMessage { .. } + ) + } +} + +pub(crate) struct VfsPragma(GraftCommand); + +impl TryFrom<&Pragma<'_>> for VfsPragma { + type Error = PragmaErr; + + fn try_from(p: &Pragma<'_>) -> Result { + Self::parse(p, false) + } } -impl GraftPragma { +impl VfsPragma { + pub(crate) fn parse( + p: &Pragma<'_>, + allow_repository_commands: bool, + ) -> Result { + let command = GraftCommand::parse(p)?; + if command.is_vfs_pragma() || allow_repository_commands { + Ok(Self(command)) + } else { + Err(pragma_fail(format!( + "repository command `{}` is not available through SQLite; use the graft CLI", + p.name + ))) + } + } + + pub(crate) fn eval( + self, + runtime: &Runtime, + file: &mut VolFile, + ) -> Result, ErrCtx> { + self.0.eval(runtime, file) + } +} + +impl GraftCommand { pub fn eval(self, _runtime: &Runtime, file: &mut VolFile) -> Result, ErrCtx> { let runtime = file.runtime().clone(); match self { - GraftPragma::VolumeList => Ok(Some(format_volumes(&runtime, file)?)), - GraftPragma::VolumeJsonList => Ok(Some(to_json(&json_volumes(&runtime, file)?)?)), - GraftPragma::Tags => { + GraftCommand::VolumeList => Ok(Some(format_volumes(&runtime, file)?)), + GraftCommand::VolumeJsonList => Ok(Some(to_json(&json_volumes(&runtime, file)?)?)), + GraftCommand::Tags => { let repo = repo_for_file(file)?; Ok(Some(format_repo_tags(&repo.tags()?)?)) } - GraftPragma::JsonTags { mode } => { + GraftCommand::JsonTags { mode } => { let repo = repo_for_file(file)?; let tags = repo.tags()?; match mode { @@ -1119,9 +1209,9 @@ impl GraftPragma { } } } - GraftPragma::VolumeTags => Ok(Some(format_tags(&runtime, file)?)), + GraftCommand::VolumeTags => Ok(Some(format_tags(&runtime, file)?)), - GraftPragma::VolumeClone { remote } => { + GraftCommand::VolumeClone { remote } => { if !file.is_idle() { return pragma_err!("cannot clone while there is an open transaction"); } @@ -1139,7 +1229,7 @@ impl GraftPragma { ))) } - GraftPragma::VolumeFork => { + GraftCommand::VolumeFork => { if !file.is_idle() { return pragma_err!("cannot fork while there is an open transaction"); } @@ -1159,16 +1249,16 @@ impl GraftPragma { } } - GraftPragma::RepoCheckout { spec } => { + GraftCommand::RepoCheckout { spec } => { let outcome = run_repo_checkout(&runtime, file, spec)?; Ok(Some(format_checkout_outcome(&outcome))) } - GraftPragma::JsonRepoCheckout { spec } => { + GraftCommand::JsonRepoCheckout { spec } => { let outcome = run_repo_checkout(&runtime, file, spec)?; Ok(Some(to_json(&outcome)?)) } - GraftPragma::Restore { spec } => { + GraftCommand::Restore { spec } => { if !file.is_idle() { return pragma_err!("cannot restore while there is an open transaction"); } @@ -1176,7 +1266,7 @@ impl GraftPragma { let outcome = restore_repo_path(&runtime, file, &repo, &spec)?; Ok(Some(format_restore_outcome(&outcome))) } - GraftPragma::JsonRestore { spec } => { + GraftCommand::JsonRestore { spec } => { if !file.is_idle() { return pragma_err!("cannot restore while there is an open transaction"); } @@ -1185,7 +1275,7 @@ impl GraftPragma { Ok(Some(to_json(&outcome)?)) } - GraftPragma::Export { spec } => { + GraftCommand::Export { spec } => { if !file.is_idle() { return pragma_err!("cannot export while there is an open transaction"); } @@ -1196,7 +1286,7 @@ impl GraftPragma { spec.output.display() ))) } - GraftPragma::JsonExport { spec } => { + GraftCommand::JsonExport { spec } => { if !file.is_idle() { return pragma_err!("cannot export while there is an open transaction"); } @@ -1214,7 +1304,7 @@ impl GraftPragma { })?)) } - GraftPragma::VolumeSwitch { vid, local, remote } => { + GraftCommand::VolumeSwitch { vid, local, remote } => { if !file.is_idle() { return pragma_err!("cannot switch while there is an open transaction"); } @@ -1228,20 +1318,20 @@ impl GraftPragma { ))) } - GraftPragma::VolumeInfo => Ok(Some(format_volume_info(&runtime, file)?)), - GraftPragma::Status { spec } => { + GraftCommand::VolumeInfo => Ok(Some(format_volume_info(&runtime, file)?)), + GraftCommand::Status { spec } => { let repo = repo_for_file(file)?; let status = repo_status_for_file(&runtime, file, &repo)?; let status = filter_repo_status_by_kind(status, spec.kind); Ok(Some(format_repo_status(&status)?)) } - GraftPragma::VolumeStatus => Ok(Some(format_volume_status(&runtime, file)?)), + GraftCommand::VolumeStatus => Ok(Some(format_volume_status(&runtime, file)?)), - GraftPragma::RepoInit { spec } => { + GraftCommand::RepoInit { spec } => { let outcome = run_repo_init(file, spec)?; Ok(Some(format_repo_init_outcome(&outcome))) } - GraftPragma::JsonRepoInit { spec } => { + GraftCommand::JsonRepoInit { spec } => { let outcome = run_repo_init(file, spec)?; Ok(Some(to_json(&JsonInitOutcome { operation: "init", @@ -1255,7 +1345,7 @@ impl GraftPragma { })?)) } - GraftPragma::RepoClone { spec } => { + GraftCommand::RepoClone { spec } => { let outcome = run_repo_clone(file, spec)?; Ok(Some(format!( "Cloned origin/{} at {} into {}", @@ -1264,7 +1354,7 @@ impl GraftPragma { outcome.graft_dir.display() ))) } - GraftPragma::JsonRepoClone { spec } => { + GraftCommand::JsonRepoClone { spec } => { let outcome = run_repo_clone(file, spec)?; Ok(Some(to_json(&JsonCloneOutcome { operation: "clone", @@ -1279,7 +1369,7 @@ impl GraftPragma { })?)) } - GraftPragma::JsonStatus { spec } => { + GraftCommand::JsonStatus { spec } => { let repo = repo_for_file(file)?; let status = repo_status_for_file(&runtime, file, &repo)?; let status = filter_repo_status_by_kind(status, spec.kind); @@ -1297,11 +1387,11 @@ impl GraftPragma { })?)) } - GraftPragma::Add { spec } => { + GraftCommand::Add { spec } => { let entries = run_repo_add(&runtime, file, &spec)?; Ok(Some(format_added_entries(&entries))) } - GraftPragma::JsonAdd { spec } => { + GraftCommand::JsonAdd { spec } => { let entries = run_repo_add(&runtime, file, &spec)?; let repo = repo_for_file(file)?; let kind = spec.kind.map(repo_tracked_path_kind_json_label); @@ -1336,11 +1426,11 @@ impl GraftPragma { })?)) } - GraftPragma::Remove { spec } => { + GraftCommand::Remove { spec } => { let paths = run_repo_remove(&runtime, file, &spec)?; Ok(Some(format_removed_paths(&paths))) } - GraftPragma::JsonRemove { spec } => { + GraftCommand::JsonRemove { spec } => { let paths = run_repo_remove(&runtime, file, &spec)?; let repo = repo_for_file(file)?; let (current_head, current_branch) = repo_head_and_branch(&repo)?; @@ -1353,12 +1443,12 @@ impl GraftPragma { })?)) } - GraftPragma::Commit { message } => { + GraftCommand::Commit { message } => { let outcome = run_repo_commit(&runtime, file, message)?; let commit = outcome.commit; Ok(Some(format!("[{}] {}", &commit.id[..12], commit.message))) } - GraftPragma::JsonCommit { message } => { + GraftCommand::JsonCommit { message } => { let outcome = run_repo_commit(&runtime, file, message)?; let head = outcome.commit.id.clone(); let paths = json_commit_path_changes(&outcome.commit); @@ -1376,7 +1466,7 @@ impl GraftPragma { })?)) } - GraftPragma::Branch { mode } => { + GraftCommand::Branch { mode } => { let repo = repo_for_file(file)?; let branches = repo.branches()?; let remote_branches = if mode.includes_remote() { @@ -1386,7 +1476,7 @@ impl GraftPragma { }; Ok(Some(format_branches(&branches, &remote_branches, mode)?)) } - GraftPragma::JsonBranch { mode } => { + GraftCommand::JsonBranch { mode } => { let repo = repo_for_file(file)?; let (current_head, current_branch) = repo_head_and_branch(&repo)?; let branches = repo.branches()?; @@ -1403,83 +1493,83 @@ impl GraftPragma { })?)) } - GraftPragma::BranchCreate { name, start_point } => { + GraftCommand::BranchCreate { name, start_point } => { let branch = run_repo_branch_create(file, name, start_point)?; Ok(Some(format_branch_created(&branch))) } - GraftPragma::JsonBranchCreate { name, start_point } => { + GraftCommand::JsonBranchCreate { name, start_point } => { let branch = run_repo_branch_create(file, name, start_point)?; let outcome = json_branch_mutation_outcome(file, "branch_create", branch, None)?; Ok(Some(to_json(&outcome)?)) } - GraftPragma::BranchDelete { name, force } => { + GraftCommand::BranchDelete { name, force } => { let branch = run_repo_branch_delete(file, name, force)?; Ok(Some(format_branch_deleted(&branch, force))) } - GraftPragma::JsonBranchDelete { name, force } => { + GraftCommand::JsonBranchDelete { name, force } => { let branch = run_repo_branch_delete(file, name, force)?; let outcome = json_branch_mutation_outcome(file, "branch_delete", branch, None)?; Ok(Some(to_json(&outcome)?)) } - GraftPragma::BranchRename { old, new, force } => { + GraftCommand::BranchRename { old, new, force } => { let (old, branch) = run_repo_branch_rename(file, old, new, force)?; Ok(Some(format_branch_renamed(&old, &branch, force))) } - GraftPragma::JsonBranchRename { old, new, force } => { + GraftCommand::JsonBranchRename { old, new, force } => { let (old, branch) = run_repo_branch_rename(file, old, new, force)?; let outcome = json_branch_mutation_outcome(file, "branch_rename", branch, Some(old))?; Ok(Some(to_json(&outcome)?)) } - GraftPragma::BranchUpstream { branch, remote, remote_branch } => { + GraftCommand::BranchUpstream { branch, remote, remote_branch } => { let branch = run_repo_branch_upstream(file, branch, remote, remote_branch)?; Ok(Some(format_branch_upstream(&branch))) } - GraftPragma::JsonBranchUpstream { branch, remote, remote_branch } => { + GraftCommand::JsonBranchUpstream { branch, remote, remote_branch } => { let branch = run_repo_branch_upstream(file, branch, remote, remote_branch)?; let outcome = json_branch_mutation_outcome(file, "branch_upstream", branch, None)?; Ok(Some(to_json(&outcome)?)) } - GraftPragma::BranchUnsetUpstream { branch } => { + GraftCommand::BranchUnsetUpstream { branch } => { let branch = run_repo_branch_unset_upstream(file, branch)?; Ok(Some(format_branch_upstream_unset(&branch))) } - GraftPragma::JsonBranchUnsetUpstream { branch } => { + GraftCommand::JsonBranchUnsetUpstream { branch } => { let branch = run_repo_branch_unset_upstream(file, branch)?; let outcome = json_branch_mutation_outcome(file, "branch_unset_upstream", branch, None)?; Ok(Some(to_json(&outcome)?)) } - GraftPragma::TagCreate { name, target, message } => { + GraftCommand::TagCreate { name, target, message } => { let tag = run_repo_tag_create(file, name, target, message)?; Ok(Some(format_tag_created(&tag))) } - GraftPragma::JsonTagCreate { name, target, message } => { + GraftCommand::JsonTagCreate { name, target, message } => { let tag = run_repo_tag_create(file, name, target, message)?; let outcome = json_tag_mutation_outcome(file, "tag_create", tag)?; Ok(Some(to_json(&outcome)?)) } - GraftPragma::TagDelete { name } => { + GraftCommand::TagDelete { name } => { let tag = run_repo_tag_delete(file, name)?; Ok(Some(format_tag_deleted(&tag))) } - GraftPragma::JsonTagDelete { name } => { + GraftCommand::JsonTagDelete { name } => { let tag = run_repo_tag_delete(file, name)?; let outcome = json_tag_mutation_outcome(file, "tag_delete", tag)?; Ok(Some(to_json(&outcome)?)) } - GraftPragma::SwitchBranch { name, force } => { + GraftCommand::SwitchBranch { name, force } => { run_repo_switch_branch(&runtime, file, name.clone(), force)?; Ok(Some(format!("Switched to branch '{name}'"))) } - GraftPragma::JsonSwitchBranch { name, force } => { + GraftCommand::JsonSwitchBranch { name, force } => { let outcome = run_repo_switch_branch(&runtime, file, name, force)?; let head = outcome.target.clone(); let repo = repo_for_file(file)?; @@ -1495,11 +1585,11 @@ impl GraftPragma { })?)) } - GraftPragma::SwitchCreate { name, start_point, force } => { + GraftCommand::SwitchCreate { name, start_point, force } => { let outcome = run_repo_switch_create(&runtime, file, name, start_point, force)?; Ok(Some(format_branch_created(&outcome.branch))) } - GraftPragma::JsonSwitchCreate { name, start_point, force } => { + GraftCommand::JsonSwitchCreate { name, start_point, force } => { let outcome = run_repo_switch_create(&runtime, file, name, start_point, force)?; let head = outcome.branch.target.clone(); let repo = repo_for_file(file)?; @@ -1515,7 +1605,7 @@ impl GraftPragma { })?)) } - GraftPragma::Merge { rev } => { + GraftCommand::Merge { rev } => { let outcome = run_repo_merge(&runtime, file, &rev)?; let repo = repo_for_file(file)?; Ok(Some(format_merge_outcome_with_row_auto_merge( @@ -1527,7 +1617,7 @@ impl GraftPragma { None, )?)) } - GraftPragma::JsonMerge { rev } => { + GraftCommand::JsonMerge { rev } => { let outcome = run_repo_merge(&runtime, file, &rev)?; let repo = repo_for_file(file)?; let conflict_analysis = @@ -1546,14 +1636,14 @@ impl GraftPragma { })?)) } - GraftPragma::MergeAbort => { + GraftCommand::MergeAbort => { let outcome = run_repo_merge_abort(&runtime, file)?; Ok(Some(format!( "Aborted merge; reset HEAD to {}", &outcome.target[..outcome.target.len().min(12)] ))) } - GraftPragma::JsonMergeAbort => { + GraftCommand::JsonMergeAbort => { let outcome = run_repo_merge_abort(&runtime, file)?; let head = outcome.target.clone(); let repo = repo_for_file(file)?; @@ -1569,7 +1659,7 @@ impl GraftPragma { })?)) } - GraftPragma::MergeContinue { message } => { + GraftCommand::MergeContinue { message } => { let outcome = run_repo_merge_continue(&runtime, file, message)?; let commit = outcome.commit; Ok(Some(format!( @@ -1578,7 +1668,7 @@ impl GraftPragma { commit.message ))) } - GraftPragma::JsonMergeContinue { message } => { + GraftCommand::JsonMergeContinue { message } => { let outcome = run_repo_merge_continue(&runtime, file, message)?; let head = outcome.commit.id.clone(); let paths = json_commit_path_changes(&outcome.commit); @@ -1596,12 +1686,12 @@ impl GraftPragma { })?)) } - GraftPragma::Conflicts => { + GraftCommand::Conflicts => { let repo = repo_for_file(file)?; Ok(Some(format_conflicts(&repo.status()?)?)) } - GraftPragma::JsonConflicts => { + GraftCommand::JsonConflicts => { let repo = repo_for_file(file)?; let remote = repo_default_remote_store(&repo); Ok(Some(to_json(&repo_conflict_artifacts( @@ -1609,7 +1699,7 @@ impl GraftPragma { )?)?)) } - GraftPragma::Resolve { spec } => { + GraftCommand::Resolve { spec } => { if !file.is_idle() { return pragma_err!("cannot resolve while there is an open transaction"); } @@ -1623,7 +1713,7 @@ impl GraftPragma { ))) } - GraftPragma::JsonResolveConflict { spec } => { + GraftCommand::JsonResolveConflict { spec } => { if !file.is_idle() { return pragma_err!("cannot resolve while there is an open transaction"); } @@ -1646,31 +1736,31 @@ impl GraftPragma { })?)) } - GraftPragma::RemoteAdd { name, config } => { + GraftCommand::RemoteAdd { name, config } => { let repo = repo_for_file(file)?; let remote = repo.remote_add(&name, config)?; Ok(Some(format_remote(&remote))) } - GraftPragma::JsonRemoteAdd { name, config } => { + GraftCommand::JsonRemoteAdd { name, config } => { let repo = repo_for_file(file)?; let remote = repo.remote_add(&name, config)?; let outcome = json_remote_mutation_outcome(file, "remote_add", remote, None)?; Ok(Some(to_json(&outcome)?)) } - GraftPragma::RemoteRemove { name } => { + GraftCommand::RemoteRemove { name } => { let repo = repo_for_file(file)?; let remote = repo.remote_remove(&name)?; Ok(Some(format!("Removed remote '{}'", remote.name))) } - GraftPragma::JsonRemoteRemove { name } => { + GraftCommand::JsonRemoteRemove { name } => { let repo = repo_for_file(file)?; let remote = repo.remote_remove(&name)?; let outcome = json_remote_mutation_outcome(file, "remote_remove", remote, None)?; Ok(Some(to_json(&outcome)?)) } - GraftPragma::RemoteRename { old, new } => { + GraftCommand::RemoteRename { old, new } => { let repo = repo_for_file(file)?; let remote = repo.remote_rename(&old, &new)?; Ok(Some(format!( @@ -1680,7 +1770,7 @@ impl GraftPragma { remote_config_uri(&remote.config) ))) } - GraftPragma::JsonRemoteRename { old, new } => { + GraftCommand::JsonRemoteRename { old, new } => { let repo = repo_for_file(file)?; let remote = repo.remote_rename(&old, &new)?; let outcome = @@ -1688,19 +1778,19 @@ impl GraftPragma { Ok(Some(to_json(&outcome)?)) } - GraftPragma::RemoteGetUrl { name } => { + GraftCommand::RemoteGetUrl { name } => { let repo = repo_for_file(file)?; let remote = repo.remote_get_url(&name)?; Ok(Some(remote_config_uri(&remote.config))) } - GraftPragma::JsonRemoteGetUrl { name } => { + GraftCommand::JsonRemoteGetUrl { name } => { let repo = repo_for_file(file)?; let remote = repo.remote_get_url(&name)?; let outcome = json_remote_mutation_outcome(file, "remote_get_url", remote, None)?; Ok(Some(to_json(&outcome)?)) } - GraftPragma::RemoteSetUrl { name, config } => { + GraftCommand::RemoteSetUrl { name, config } => { let repo = repo_for_file(file)?; let remote = repo.remote_set_url(&name, config)?; Ok(Some(format!( @@ -1709,19 +1799,19 @@ impl GraftPragma { remote_config_uri(&remote.config) ))) } - GraftPragma::JsonRemoteSetUrl { name, config } => { + GraftCommand::JsonRemoteSetUrl { name, config } => { let repo = repo_for_file(file)?; let remote = repo.remote_set_url(&name, config)?; let outcome = json_remote_mutation_outcome(file, "remote_set_url", remote, None)?; Ok(Some(to_json(&outcome)?)) } - GraftPragma::RemotePrune { name } => { + GraftCommand::RemotePrune { name } => { let repo = repo_for_file(file)?; let outcome = repo.remote_prune(&name)?; Ok(Some(format_remote_prune_outcome(&outcome)?)) } - GraftPragma::JsonRemotePrune { name } => { + GraftCommand::JsonRemotePrune { name } => { let repo = repo_for_file(file)?; let outcome = repo.remote_prune(&name)?; let (current_head, current_branch) = repo_head_and_branch(&repo)?; @@ -1733,7 +1823,7 @@ impl GraftPragma { })?)) } - GraftPragma::LsRemote { name } => { + GraftCommand::LsRemote { name } => { let repo = repo_for_file(file)?; let default_branch = repo.remote_default_branch(&name)?; let refs = repo.remote_branch_refs(&name)?; @@ -1743,7 +1833,7 @@ impl GraftPragma { &refs, )?)) } - GraftPragma::JsonLsRemote { name } => { + GraftCommand::JsonLsRemote { name } => { let repo = repo_for_file(file)?; let default_branch = repo.remote_default_branch(&name)?; let refs = repo.remote_branch_refs(&name)?; @@ -1758,11 +1848,11 @@ impl GraftPragma { })?)) } - GraftPragma::Remotes => { + GraftCommand::Remotes => { let repo = repo_for_file(file)?; Ok(Some(format_remotes(&repo.remotes()?)?)) } - GraftPragma::JsonRemotes => { + GraftCommand::JsonRemotes => { let repo = repo_for_file(file)?; let (current_head, current_branch) = repo_head_and_branch(&repo)?; Ok(Some(to_json(&JsonRemoteList { @@ -1772,22 +1862,22 @@ impl GraftPragma { })?)) } - GraftPragma::VolumeSnapshot => { + GraftCommand::VolumeSnapshot => { let snapshot = file.snapshot_or_latest()?; Ok(Some(format!("{snapshot:?}"))) } - GraftPragma::Fetch { remote, branch, refspec, all } => { + GraftCommand::Fetch { remote, branch, refspec, all } => { let repo = repo_for_file(file)?; Ok(Some(run_repo_fetch(&repo, remote, branch, refspec, all)?)) } - GraftPragma::JsonFetch { remote, branch, refspec, all } => { + GraftCommand::JsonFetch { remote, branch, refspec, all } => { let repo = repo_for_file(file)?; Ok(Some(run_repo_fetch_json( &repo, remote, branch, refspec, all, )?)) } - GraftPragma::FetchAsync { remote, branch, refspec, all } => { + GraftCommand::FetchAsync { remote, branch, refspec, all } => { repo_for_file(file)?; let id = async_jobs().spawn_fetch( PathBuf::from(file.tag.clone()), @@ -1799,7 +1889,7 @@ impl GraftPragma { ); Ok(Some(id)) } - GraftPragma::JsonFetchAsync { remote, branch, refspec, all, mode } => { + GraftCommand::JsonFetchAsync { remote, branch, refspec, all, mode } => { repo_for_file(file)?; let id = async_jobs().spawn_fetch( PathBuf::from(file.tag.clone()), @@ -1814,11 +1904,11 @@ impl GraftPragma { JsonFetchAsyncMode::WithStatus => Ok(Some(async_jobs().json_status(&id)?)), } } - GraftPragma::JobStatus { id } => Ok(Some(async_jobs().status_json(&id)?)), - GraftPragma::JsonJobStatus { id } => Ok(Some(async_jobs().json_status(&id)?)), - GraftPragma::JobResult { id } => Ok(Some(async_jobs().result(&id)?)), - GraftPragma::JsonJobResult { id } => Ok(Some(async_jobs().result(&id)?)), - GraftPragma::Pull { remote, branch, refspec, all } => { + GraftCommand::JobStatus { id } => Ok(Some(async_jobs().status_json(&id)?)), + GraftCommand::JsonJobStatus { id } => Ok(Some(async_jobs().json_status(&id)?)), + GraftCommand::JobResult { id } => Ok(Some(async_jobs().result(&id)?)), + GraftCommand::JsonJobResult { id } => Ok(Some(async_jobs().result(&id)?)), + GraftCommand::Pull { remote, branch, refspec, all } => { let outcome = run_repo_pull(&runtime, file, remote, branch, refspec, all)?; let repo = repo_for_file(file)?; let checkout_remote = Arc::new(repo.remote_store(&outcome.outcome.remote)?); @@ -1830,7 +1920,7 @@ impl GraftPragma { Some(checkout_remote), )?)) } - GraftPragma::JsonPull { remote, branch, refspec, all } => { + GraftCommand::JsonPull { remote, branch, refspec, all } => { let outcome = run_repo_pull(&runtime, file, remote, branch, refspec, all)?; let repo = repo_for_file(file)?; let remote = repo @@ -1849,23 +1939,25 @@ impl GraftPragma { })?)) } - GraftPragma::Push { remote, branch, refspec, all, force } => { + GraftCommand::Push { remote, branch, refspec, all, force } => { let repo = repo_for_file(file)?; let outcome = run_repo_push(&runtime, &repo, remote, branch, refspec, all, force)?; Ok(Some(format_push_command_outcome(&outcome)?)) } - GraftPragma::JsonPush { remote, branch, refspec, all, force } => { + GraftCommand::JsonPush { remote, branch, refspec, all, force } => { let repo = repo_for_file(file)?; let outcome = run_repo_push(&runtime, &repo, remote, branch, refspec, all, force)?; Ok(Some(to_json(&json_push_command_outcome(&repo, &outcome)?)?)) } - GraftPragma::VolumeFetch => Ok(Some(fetch_or_pull(&runtime, file, false)?)), - GraftPragma::VolumePull => Ok(Some(fetch_or_pull(&runtime, file, true)?)), - GraftPragma::VolumePush => Ok(Some(push(&runtime, file)?)), + GraftCommand::VolumeFetch => Ok(Some(fetch_or_pull(&runtime, file, false)?)), + GraftCommand::VolumePull => Ok(Some(fetch_or_pull(&runtime, file, true)?)), + GraftCommand::VolumePush => Ok(Some(push(&runtime, file)?)), - GraftPragma::VolumeAudit => Ok(Some(format_volume_audit(&runtime, file)?)), - GraftPragma::VolumeJsonAudit => Ok(Some(to_json(&json_volume_audit(&runtime, file)?)?)), - GraftPragma::RepoAudit { spec } => { + GraftCommand::VolumeAudit => Ok(Some(format_volume_audit(&runtime, file)?)), + GraftCommand::VolumeJsonAudit => { + Ok(Some(to_json(&json_volume_audit(&runtime, file)?)?)) + } + GraftCommand::RepoAudit { spec } => { let repo = repo_for_file(file)?; if spec.repair { let remote = repo_default_remote(&repo, spec.remote.clone())?; @@ -1875,7 +1967,7 @@ impl GraftPragma { Ok(Some(format_repo_artifact_audit(&repo.audit_artifacts()?)?)) } } - GraftPragma::JsonRepoAudit { spec } => { + GraftCommand::JsonRepoAudit { spec } => { let repo = repo_for_file(file)?; let (current_head, current_branch) = repo_head_and_branch(&repo)?; if spec.repair { @@ -1895,13 +1987,13 @@ impl GraftPragma { })?)) } } - GraftPragma::LargeFileFetch { spec } => { + GraftCommand::LargeFileFetch { spec } => { let repo = repo_for_file(file)?; let remote = repo_default_remote(&repo, spec.remote.clone())?; let outcome = repo.fetch_large_file_payloads(&remote, spec.rev.as_deref())?; Ok(Some(format_large_file_fetch_outcome(&outcome)?)) } - GraftPragma::JsonLargeFileFetch { spec, operation } => { + GraftCommand::JsonLargeFileFetch { spec, operation } => { let repo = repo_for_file(file)?; let (current_head, current_branch) = repo_head_and_branch(&repo)?; let remote = repo_default_remote(&repo, spec.remote.clone())?; @@ -1913,12 +2005,12 @@ impl GraftPragma { outcome, })?)) } - GraftPragma::LargeFileStatus { spec } => { + GraftCommand::LargeFileStatus { spec } => { let repo = repo_for_file(file)?; let outcome = repo.large_file_payloads_status(spec.rev.as_deref())?; Ok(Some(format_large_file_status_outcome(&outcome)?)) } - GraftPragma::JsonLargeFileStatus { spec, operation } => { + GraftCommand::JsonLargeFileStatus { spec, operation } => { let repo = repo_for_file(file)?; let (current_head, current_branch) = repo_head_and_branch(&repo)?; let outcome = repo.large_file_payloads_status(spec.rev.as_deref())?; @@ -1929,12 +2021,12 @@ impl GraftPragma { outcome, })?)) } - GraftPragma::LargeFilePrune { spec } => { + GraftCommand::LargeFilePrune { spec } => { let repo = repo_for_file(file)?; let outcome = repo.prune_large_file_payloads(spec.dry_run)?; Ok(Some(format_large_file_prune_outcome(&outcome)?)) } - GraftPragma::JsonLargeFilePrune { spec, operation } => { + GraftCommand::JsonLargeFilePrune { spec, operation } => { let repo = repo_for_file(file)?; let (current_head, current_branch) = repo_head_and_branch(&repo)?; let outcome = repo.prune_large_file_payloads(spec.dry_run)?; @@ -1945,11 +2037,11 @@ impl GraftPragma { outcome, })?)) } - GraftPragma::StorageGc { spec } => { + GraftCommand::StorageGc { spec } => { let outcome = run_repo_storage_gc(&runtime, file, spec.dry_run)?; Ok(Some(format_storage_gc_outcome(&outcome)?)) } - GraftPragma::JsonStorageGc { spec } => { + GraftCommand::JsonStorageGc { spec } => { let repo = repo_for_file(file)?; let (current_head, current_branch) = repo_head_and_branch(&repo)?; let outcome = run_repo_storage_gc(&runtime, file, spec.dry_run)?; @@ -1960,7 +2052,7 @@ impl GraftPragma { outcome, })?)) } - GraftPragma::LsFiles { spec } => { + GraftCommand::LsFiles { spec } => { let repo = repo_for_file(file)?; if spec.others { let paths = filter_tracked_paths_by_kind(repo.untracked_paths()?, spec.kind); @@ -1982,7 +2074,7 @@ impl GraftPragma { Ok(Some(format_repo_tracked_paths(&paths)?)) } } - GraftPragma::JsonLsFiles { spec } => { + GraftCommand::JsonLsFiles { spec } => { let repo = repo_for_file(file)?; let (current_head, current_branch) = repo_head_and_branch(&repo)?; let kind = spec.kind.map(repo_tracked_path_kind_json_label); @@ -2038,11 +2130,11 @@ impl GraftPragma { })?)) } } - GraftPragma::ConfigGet { key } => { + GraftCommand::ConfigGet { key } => { let repo = repo_for_file(file)?; Ok(Some(format_repo_config_entry(&repo.config_get(&key)?)?)) } - GraftPragma::JsonConfigGet { key } => { + GraftCommand::JsonConfigGet { key } => { let repo = repo_for_file(file)?; let entry = repo.config_get(&key)?; let (current_head, current_branch) = repo_head_and_branch(&repo)?; @@ -2052,11 +2144,11 @@ impl GraftPragma { entry, })?)) } - GraftPragma::ConfigList => { + GraftCommand::ConfigList => { let repo = repo_for_file(file)?; Ok(Some(format_repo_config_entries(&repo.config_list()?)?)) } - GraftPragma::JsonConfigList { mode } => { + GraftCommand::JsonConfigList { mode } => { let repo = repo_for_file(file)?; let entries = repo.config_list()?; match mode { @@ -2071,13 +2163,13 @@ impl GraftPragma { } } } - GraftPragma::ConfigSet { key, value } => { + GraftCommand::ConfigSet { key, value } => { let repo = repo_for_file(file)?; Ok(Some(format_repo_config_entry( &repo.config_set(&key, &value)?, )?)) } - GraftPragma::JsonConfigSet { key, value } => { + GraftCommand::JsonConfigSet { key, value } => { let repo = repo_for_file(file)?; let entry = repo.config_set(&key, &value)?; let (current_head, current_branch) = repo_head_and_branch(&repo)?; @@ -2088,11 +2180,11 @@ impl GraftPragma { entry, })?)) } - GraftPragma::ConfigUnset { key } => { + GraftCommand::ConfigUnset { key } => { let repo = repo_for_file(file)?; Ok(Some(format_repo_config_entry(&repo.config_unset(&key)?)?)) } - GraftPragma::JsonConfigUnset { key } => { + GraftCommand::JsonConfigUnset { key } => { let repo = repo_for_file(file)?; let entry = repo.config_unset(&key)?; let (current_head, current_branch) = repo_head_and_branch(&repo)?; @@ -2104,13 +2196,13 @@ impl GraftPragma { })?)) } - GraftPragma::VolumeHydrate => { + GraftCommand::VolumeHydrate => { let snapshot = file.snapshot_or_latest()?; runtime.snapshot_hydrate(snapshot)?; Ok(None) } - GraftPragma::Version => { + GraftCommand::Version => { const PKG_VERSION: &str = env!("CARGO_PKG_VERSION"); const GITHUB_SHA: Option<&str> = option_env!("GITHUB_SHA"); let mut out = format!("Graft Version: {PKG_VERSION}"); @@ -2120,15 +2212,15 @@ impl GraftPragma { Ok(Some(out)) } - GraftPragma::VolumeImport => { + GraftCommand::VolumeImport => { pragma_err!( "deprecated: use `vacuum into` instead: https://graft.rs/r/graft_import" ) } - GraftPragma::VolumeExport(path) => volume_export(&runtime, file, path).map(Some), + GraftCommand::VolumeExport(path) => volume_export(&runtime, file, path).map(Some), - GraftPragma::VolumeDumpSqliteHeader => { + GraftCommand::VolumeDumpSqliteHeader => { let reader = runtime.volume_reader(file.vid.clone())?; let page = reader.read_page(PageIdx::FIRST)?; let header = SqliteHeader::read_from_bytes(&page[..100]) @@ -2136,17 +2228,17 @@ impl GraftPragma { Ok(Some(format!("{header:#?}"))) } - GraftPragma::VolumeDumpCommit { logref } => { + GraftCommand::VolumeDumpCommit { logref } => { format_debug_show_lsn(&runtime, &logref).map(Some) } - GraftPragma::DebugLogLsn => format_debug_log_lsn(&runtime, file).map(Some), + GraftCommand::DebugLogLsn => format_debug_log_lsn(&runtime, file).map(Some), - GraftPragma::DebugShowLsn { logref } => { + GraftCommand::DebugShowLsn { logref } => { format_debug_show_lsn(&runtime, &logref).map(Some) } - GraftPragma::DebugDiffLsn { from, to } => { + GraftCommand::DebugDiffLsn { from, to } => { if from.log != to.log { return pragma_err!("debug LSN diff requires both refs to use the same log"); } @@ -2154,12 +2246,12 @@ impl GraftPragma { Ok(Some(format_debug_page_diff(&diff))) } - GraftPragma::Log => { + GraftCommand::Log => { let repo = repo_for_file(file)?; Ok(Some(format_repo_log(&repo)?)) } - GraftPragma::VolumeCheckoutLsn { lsn } => { + GraftCommand::VolumeCheckoutLsn { lsn } => { if !file.is_idle() { return pragma_err!("cannot checkout while there is an open transaction"); } @@ -2173,7 +2265,7 @@ impl GraftPragma { ))) } - GraftPragma::VolumeResetTo { lsn } => { + GraftCommand::VolumeResetTo { lsn } => { if !file.is_idle() { return pragma_err!("cannot reset while there is an open transaction"); } @@ -2188,7 +2280,7 @@ impl GraftPragma { ))) } - GraftPragma::Reset { rev, mode } => { + GraftCommand::Reset { rev, mode } => { let outcome = run_repo_reset(&runtime, file, &rev, mode)?; Ok(Some(format!( @@ -2197,7 +2289,7 @@ impl GraftPragma { reset_mode_label(mode) ))) } - GraftPragma::JsonReset { rev, mode } => { + GraftCommand::JsonReset { rev, mode } => { let outcome = run_repo_reset(&runtime, file, &rev, mode)?; let head = outcome.outcome.target.clone(); let repo = repo_for_file(file)?; @@ -2213,7 +2305,7 @@ impl GraftPragma { })?)) } - GraftPragma::VolumeDiff { from, to, mode } => { + GraftCommand::VolumeDiff { from, to, mode } => { if !file.is_idle() { return pragma_err!("cannot diff while there is an open transaction"); } @@ -2231,7 +2323,7 @@ impl GraftPragma { } } - GraftPragma::RepoDiff { spec } => { + GraftCommand::RepoDiff { spec } => { if !file.is_idle() { return pragma_err!("cannot diff while there is an open transaction"); } @@ -2247,7 +2339,7 @@ impl GraftPragma { } } - GraftPragma::Show { target } => { + GraftCommand::Show { target } => { if !file.is_idle() { return pragma_err!("cannot show while there is an open transaction"); } @@ -2256,7 +2348,7 @@ impl GraftPragma { Ok(Some(format_repo_show(&commit)?)) } - GraftPragma::JsonLog { spec } => { + GraftCommand::JsonLog { spec } => { let repo = repo_for_file(file)?; let (commits, has_more) = match spec.limit { Some(limit) => repo.log_page(limit, spec.after.as_deref())?, @@ -2280,7 +2372,7 @@ impl GraftPragma { } } - GraftPragma::VolumeJsonDiff { from, to, mode } => { + GraftCommand::VolumeJsonDiff { from, to, mode } => { if !file.is_idle() { return pragma_err!("cannot diff while there is an open transaction"); } @@ -2400,7 +2492,7 @@ impl GraftPragma { } } - GraftPragma::JsonRepoDiff { spec } => { + GraftCommand::JsonRepoDiff { spec } => { if !file.is_idle() { return pragma_err!("cannot diff while there is an open transaction"); } @@ -2447,7 +2539,7 @@ impl GraftPragma { } } - GraftPragma::JsonShow { target } => { + GraftCommand::JsonShow { target } => { if !file.is_idle() { return pragma_err!("cannot show while there is an open transaction"); } @@ -2461,14 +2553,14 @@ impl GraftPragma { })?)) } - GraftPragma::VolumeJsonInfo => { + GraftCommand::VolumeJsonInfo => { let result = json_volume_info(&runtime, file)?; Ok(Some(serde_json::to_string(&result).map_err(|e| { ErrCtx::PragmaErr(format!("JSON error: {e}").into()) })?)) } - GraftPragma::VolumeTableLog { table } => { + GraftCommand::VolumeTableLog { table } => { let entries = table_log_entries(&runtime, &file.vid, &table)?; if entries.is_empty() { return Ok(Some(format!("No changes found for table '{table}'."))); @@ -2491,7 +2583,7 @@ impl GraftPragma { Ok(Some(f)) } - GraftPragma::VolumeJsonTableLog { table } => { + GraftCommand::VolumeJsonTableLog { table } => { let entries = table_log_entries(&runtime, &file.vid, &table)?; let json_entries: Vec = entries .iter() @@ -2507,7 +2599,7 @@ impl GraftPragma { })?)) } - GraftPragma::VolumeSetMessage { message } => { + GraftCommand::VolumeSetMessage { message } => { file.pending_message = Some(message.clone()); Ok(Some(format!("Commit message set: '{message}'"))) } diff --git a/crates/graft-sqlite/src/pragma/parse.rs b/crates/graft-sqlite/src/pragma/parse.rs index 75b7cd04..064b7dbf 100644 --- a/crates/graft-sqlite/src/pragma/parse.rs +++ b/crates/graft-sqlite/src/pragma/parse.rs @@ -109,7 +109,10 @@ pub(super) fn parse_remote_config_uri(uri: &str) -> Result Result Result<(&str, Option Result<(&str, Option), PragmaErr> { - let (path, query) = uri + if uri.contains('#') { + return Err(pragma_fail( + "Graft HTTP remote URI must not include a fragment", + )); + } + + let (location, query) = uri .split_once('?') - .map_or((uri, ""), |(path, query)| (path, query)); - if path.is_empty() { + .map_or((uri, None), |(location, query)| (location, Some(query))); + validate_http_remote_location(location)?; + let token_env = match query { + Some(query) => parse_http_remote_query(query)?, + None => None, + }; + Ok((location, token_env)) +} + +fn validate_http_remote_location(location: &str) -> Result<(), PragmaErr> { + let Some((authority, repository_path)) = location.split_once('/') else { + return Err(pragma_fail( + "Graft HTTP remote URI must include an authority and repository path", + )); + }; + if repository_path.is_empty() { + return Err(pragma_fail( + "Graft HTTP remote URI must include an authority and repository path", + )); + } + validate_http_remote_authority(authority)?; + if repository_path.contains('\\') { + return Err(pragma_fail( + "Graft HTTP remote repository path must not contain backslashes", + )); + } + validate_http_remote_path_segments(repository_path) +} + +fn validate_http_remote_authority(authority: &str) -> Result<(), PragmaErr> { + if authority.is_empty() { + return Err(pragma_fail( + "Graft HTTP remote URI must include an authority and repository path", + )); + } + if authority.contains('@') { + return Err(pragma_fail( + "Graft HTTP remote URI must not include userinfo", + )); + } + if authority.contains('\\') + || authority + .chars() + .any(|character| character.is_whitespace() || character.is_control()) + { return Err(pragma_fail( - "Graft HTTP remote URI must include a host and path", + "Graft HTTP remote URI has an invalid authority", )); } + let valid = if authority.starts_with('[') { + validate_bracketed_http_remote_authority(authority) + } else { + let (host, port) = authority + .rsplit_once(':') + .map_or((authority, None), |(host, port)| (host, Some(port))); + !host.is_empty() + && !host.contains([':', '[', ']']) + && port.is_none_or(valid_http_remote_port) + }; + if !valid { + return Err(pragma_fail( + "Graft HTTP remote URI has an invalid authority", + )); + } + Ok(()) +} + +fn validate_bracketed_http_remote_authority(authority: &str) -> bool { + let Some(closing_bracket) = authority.find(']') else { + return false; + }; + if authority[1..closing_bracket] + .parse::() + .is_err() + { + return false; + } + let suffix = &authority[closing_bracket + 1..]; + suffix.is_empty() || suffix.strip_prefix(':').is_some_and(valid_http_remote_port) +} + +fn valid_http_remote_port(port: &str) -> bool { + !port.is_empty() + && port.bytes().all(|byte| byte.is_ascii_digit()) + && port.parse::().is_ok() +} + +fn validate_http_remote_path_segments(repository_path: &str) -> Result<(), PragmaErr> { + for segment in repository_path.split('/') { + if segment.is_empty() { + return Err(pragma_fail( + "Graft HTTP remote repository path must not contain empty segments", + )); + } + if is_dot_segment(segment) { + return Err(pragma_fail( + "Graft HTTP remote repository path must not contain dot segments", + )); + } + } + Ok(()) +} + +fn is_dot_segment(segment: &str) -> bool { + segment == "." + || segment == ".." + || segment.eq_ignore_ascii_case("%2e") + || segment.eq_ignore_ascii_case(".%2e") + || segment.eq_ignore_ascii_case("%2e.") + || segment.eq_ignore_ascii_case("%2e%2e") +} + +fn parse_http_remote_query(query: &str) -> Result, PragmaErr> { if query.is_empty() { - return Ok((path, None)); + return Err(pragma_fail( + "Graft HTTP remote URI query must contain token_env", + )); } let mut token_env = None; - for part in query.split('&').filter(|part| !part.is_empty()) { + for part in query.split('&') { + if part.is_empty() { + return Err(pragma_fail( + "Graft HTTP remote URI query must not contain empty parameters", + )); + } let (key, value) = part .split_once('=') .map_or((part, ""), |(key, value)| (key, value)); @@ -195,7 +318,7 @@ pub(super) fn parse_http_remote_uri_query(uri: &str) -> Result<(&str, Option WorkspaceCheckout<'a> { previous_artifacts: &BTreeMap, remote: Option>, ) -> Result<(), ErrCtx> { - preflight_workspace_checkout(self.repo, plan, previous_files)?; let bindings = prepare_workspace_bindings(self.runtime, plan, remote)?; let previous_bindings = self.previous_bindings(plan, previous_files)?; let backups = WorkspacePhysicalBackups::stage(self.repo, plan, previous_files)?; @@ -833,11 +833,18 @@ pub(super) fn preflight_workspace_checkout( repo: &Repository, plan: &CheckoutPlan, previous_files: &BTreeMap, -) -> Result<(), ErrCtx> { +) -> Result, ErrCtx> { + let mut guards = Vec::new(); for key in workspace_sqlite_keys(plan, previous_files) { let path = repo.worktree().join(&key); match std::fs::symlink_metadata(&path) { - Ok(metadata) if metadata.file_type().is_file() => {} + Ok(metadata) if metadata.file_type().is_file() => { + // Repository state is updated only after this preflight succeeds. Check ordinary + // SQLite locks and detach any WAL before the checkout backup can rename the file. + // Retain the exclusive transaction until materialization completes so an external + // writer cannot enter between this preflight and the destructive operation. + guards.push(prepare_sqlite_path_for_replacement(&path)?); + } Ok(_) => { return pragma_err!(format!( "path `{}` is not a regular SQLite database file", @@ -851,7 +858,7 @@ pub(super) fn preflight_workspace_checkout( for state in plan.artifacts.values() { repo.verify_artifact_state(state)?; } - Ok(()) + Ok(guards) } struct WorkspacePhysicalBackups { @@ -1715,18 +1722,28 @@ pub(super) fn export_repo_path( } let current_key = repo.file_key(&file.tag)?; - if key != current_key { - return Err(ErrCtx::PragmaErr( - format!( - "exporting worktree path `{key}` requires opening that database path or passing --source" - ) - .into(), - )); + if key == current_key { + let reader = file.reader()?; + write_volume_reader_to_path(&reader, &spec.output)?; + return Ok(key); + } + + // The default worktree is now a physical SQLite file. Export it through the same online + // backup boundary used by `add` so a committed WAL is included and the output is a standalone + // rollback-journal database. This also avoids treating the CLI's control session as a VFS + // Volume binding. + if physical_path.exists() && is_sqlite_database_path(&physical_path)? { + let reader = PhysicalSqliteReader::open(&physical_path)?; + write_volume_reader_to_path(&reader, &spec.output)?; + return Ok(key); } - let reader = file.reader()?; - write_volume_reader_to_path(&reader, &spec.output)?; - Ok(key) + Err(ErrCtx::PragmaErr( + format!( + "worktree database `{key}` does not exist; pass --source to export a repository revision" + ) + .into(), + )) } pub(super) fn update_worktree_state_after_index_restore_key( @@ -1847,6 +1864,31 @@ pub(super) fn checkout_repo_file_state_to_key( bind_repo_file_state_to_path(runtime, state, &path) } +pub(super) fn checkout_repo_file_state_to_prepared_key( + runtime: &Runtime, + repo: &Repository, + key: &str, + state: &CommitFileState, + remote: Option>, +) -> Result<(), ErrCtx> { + let path = repo.worktree().join(key); + if let Ok(metadata) = std::fs::symlink_metadata(&path) + && !metadata.file_type().is_file() + { + return Err(ErrCtx::PragmaErr( + format!( + "path `{}` is not a regular SQLite database file", + path.display() + ) + .into(), + )); + } + + hydrate_repo_file_state(runtime, state, remote)?; + write_repo_file_state_to_prepared_path(runtime, state, &path)?; + bind_repo_file_state_to_path(runtime, state, &path) +} + fn bind_repo_file_state_to_path( runtime: &Runtime, state: &CommitFileState, @@ -1893,8 +1935,23 @@ pub(super) fn write_volume_reader_to_path( } pub(super) fn write_sqlite_file_to_path( + path: &Path, + write_contents: impl FnMut(&mut File) -> Result<(), ErrCtx>, +) -> Result<(), ErrCtx> { + write_sqlite_file_to_path_inner(path, write_contents, true) +} + +fn write_sqlite_file_to_prepared_path( + path: &Path, + write_contents: impl FnMut(&mut File) -> Result<(), ErrCtx>, +) -> Result<(), ErrCtx> { + write_sqlite_file_to_path_inner(path, write_contents, false) +} + +fn write_sqlite_file_to_path_inner( path: &Path, mut write_contents: impl FnMut(&mut File) -> Result<(), ErrCtx>, + prepare_replacement: bool, ) -> Result<(), ErrCtx> { if let Ok(metadata) = std::fs::symlink_metadata(path) && !metadata.file_type().is_file() @@ -1938,6 +1995,9 @@ pub(super) fn write_sqlite_file_to_path( })(); match write_result.and_then(|()| { + let _replacement_guard = prepare_replacement + .then(|| prepare_sqlite_path_for_replacement(path)) + .transpose()?; std::fs::rename(&tmp, path)?; Ok(()) }) { @@ -1971,6 +2031,25 @@ pub(super) fn write_repo_file_state_to_path( write_volume_reader_to_path(&reader, path) } +fn write_repo_file_state_to_prepared_path( + runtime: &Runtime, + state: &CommitFileState, + path: &Path, +) -> Result<(), ErrCtx> { + let snapshot = state.snapshot.to_snapshot(); + if snapshot.is_empty() { + return write_sqlite_file_to_prepared_path(path, |_| Ok(())); + } + let reader = runtime.snapshot_reader(snapshot); + write_sqlite_file_to_prepared_path(path, |output| { + for page_idx in reader.page_count().iter() { + let page = reader.read_page(page_idx)?; + output.write_all(page.as_ref())?; + } + Ok(()) + }) +} + pub(super) fn checkout_merge_outcome( runtime: &Runtime, file: &mut VolFile, diff --git a/crates/graft-sqlite/src/pragma/repo_conflicts.rs b/crates/graft-sqlite/src/pragma/repo_conflicts.rs index 5a30f76f..be2264a4 100644 --- a/crates/graft-sqlite/src/pragma/repo_conflicts.rs +++ b/crates/graft-sqlite/src/pragma/repo_conflicts.rs @@ -129,9 +129,11 @@ pub(super) fn resolve_repo_conflict_for_file( path_storage, }); } - ResolveSide::Manual if physical_path.exists() => { - Some(import_physical_sqlite_file_state(runtime, &physical_path)?) - } + ResolveSide::Manual if physical_path.exists() => Some(import_physical_sqlite_file_state( + runtime, + &physical_path, + None, + )?), ResolveSide::Manual => None, }; let entry = repo.resolve_file_conflict(&physical_path, state)?; diff --git a/crates/graft-sqlite/src/pragma/repo_core.rs b/crates/graft-sqlite/src/pragma/repo_core.rs index bff793e0..1ebde5d1 100644 --- a/crates/graft-sqlite/src/pragma/repo_core.rs +++ b/crates/graft-sqlite/src/pragma/repo_core.rs @@ -99,7 +99,8 @@ pub(super) fn run_repo_clone( let previous_files = BTreeMap::new(); let previous_artifacts = BTreeMap::new(); let paths = checkout_plan_path_actions(&plan, &previous_files, &previous_artifacts); - preflight_workspace_checkout(&repo, &plan, &previous_files)?; + let _sqlite_replacement_guards = + preflight_workspace_checkout(&repo, &plan, &previous_files)?; repo.apply_switch_branch_plan(&branch, &plan)?; checkout_repo_plan( &runtime, diff --git a/crates/graft-sqlite/src/pragma/repo_merge.rs b/crates/graft-sqlite/src/pragma/repo_merge.rs index 33928d6f..5967644f 100644 --- a/crates/graft-sqlite/src/pragma/repo_merge.rs +++ b/crates/graft-sqlite/src/pragma/repo_merge.rs @@ -13,7 +13,8 @@ pub(super) fn run_repo_merge_abort( let previous_files = current_repo_files_for_checkout(&repo)?; let previous_artifacts = current_repo_artifacts_for_checkout(&repo)?; let paths = checkout_plan_path_actions(&plan.checkout, &previous_files, &previous_artifacts); - preflight_workspace_checkout(&repo, &plan.checkout, &previous_files)?; + let _sqlite_replacement_guards = + preflight_workspace_checkout(&repo, &plan.checkout, &previous_files)?; let target = repo.apply_merge_abort_plan(&plan)?; checkout_repo_plan( runtime, @@ -69,7 +70,8 @@ pub(super) fn run_repo_merge( ensure_checkout_plan_preserves_untracked_paths(runtime, file, &repo, &plan.checkout)?; let previous_files = current_repo_files_for_checkout(&repo)?; let previous_artifacts = current_repo_artifacts_for_checkout(&repo)?; - preflight_workspace_checkout(&repo, &plan.checkout, &previous_files)?; + let _sqlite_replacement_guards = + preflight_workspace_checkout(&repo, &plan.checkout, &previous_files)?; let mut outcome = repo.apply_merge_plan(&plan)?; checkout_merge_outcome( runtime, @@ -81,14 +83,15 @@ pub(super) fn run_repo_merge( &previous_artifacts, None, )?; - let row_auto_merge = - match try_row_auto_merge_current_file_conflict(runtime, file, &repo, &outcome, None) { - Ok(row_auto_merge) => row_auto_merge, - Err(err) => { - tracing::warn!("row-level auto-merge unavailable: {err}"); - None - } - }; + let row_auto_merge = match try_row_auto_merge_current_file_conflict( + runtime, file, &repo, &outcome, None, true, + ) { + Ok(row_auto_merge) => row_auto_merge, + Err(err) => { + tracing::warn!("row-level auto-merge unavailable: {err}"); + None + } + }; if let Some(row_auto_merge) = &row_auto_merge { outcome = merge_outcome_with_row_auto_merge(&outcome, &row_auto_merge.key); } diff --git a/crates/graft-sqlite/src/pragma/repo_remote_output.rs b/crates/graft-sqlite/src/pragma/repo_remote_output.rs index 87061fa4..028feedf 100644 --- a/crates/graft-sqlite/src/pragma/repo_remote_output.rs +++ b/crates/graft-sqlite/src/pragma/repo_remote_output.rs @@ -100,7 +100,7 @@ pub(super) fn remote_config_uri(config: &RemoteConfig) -> String { } RemoteConfig::Http { url, token_env } => { let mut uri = if let Some(rest) = url.strip_prefix("https://") { - format!("graft+https://{rest}") + format!("https://{rest}") } else if let Some(rest) = url.strip_prefix("http://") { format!("graft+http://{rest}") } else { diff --git a/crates/graft-sqlite/src/pragma/repo_staging.rs b/crates/graft-sqlite/src/pragma/repo_staging.rs index cc7da305..600e7c92 100644 --- a/crates/graft-sqlite/src/pragma/repo_staging.rs +++ b/crates/graft-sqlite/src/pragma/repo_staging.rs @@ -583,7 +583,12 @@ pub(super) fn prepare_repo_add_file( repo.prepare_file_state_path(repo.worktree().join(key), state) .map_err(Into::into) } else if is_sqlite_database_path(physical_path)? { - let state = import_physical_sqlite_file_state(runtime, physical_path)?; + let base = repo + .index_files()? + .get(key) + .cloned() + .or(repo.head_file(physical_path)?); + let state = import_physical_sqlite_file_state(runtime, physical_path, base.as_ref())?; repo.prepare_file_state_path(repo.worktree().join(key), state) .map_err(Into::into) } else if let Some(state) = repo_file_state_for_key(runtime, repo, key)? { @@ -943,6 +948,7 @@ pub(super) fn remove_physical_sqlite_file( .into(), )); } + let _replacement_guard = prepare_sqlite_path_for_replacement(path)?; std::fs::remove_file(path)?; } Err(err) if err.kind() == std::io::ErrorKind::NotFound => {} @@ -968,174 +974,3 @@ pub(super) fn remove_physical_artifact_file(path: &Path) -> Result<(), ErrCtx> { Ok(()) } - -pub(super) struct PhysicalSqliteReader { - input: Mutex, - path: PathBuf, - snapshot: graft::snapshot::Snapshot, -} - -impl PhysicalSqliteReader { - pub(super) fn open(path: &Path) -> Result { - let metadata = std::fs::symlink_metadata(path)?; - if !metadata.file_type().is_file() { - return Err(ErrCtx::PragmaErr( - format!( - "path `{}` is not a regular SQLite database file", - path.display() - ) - .into(), - )); - } - - if metadata.len() < 100 { - return Err(ErrCtx::PragmaErr( - format!("path `{}` is not a SQLite database", path.display()).into(), - )); - } - - let mut input = File::open(path)?; - let mut header = [0_u8; 100]; - input.read_exact(&mut header)?; - if &header[..SQLITE_DATABASE_MAGIC.len()] != SQLITE_DATABASE_MAGIC { - return Err(ErrCtx::PragmaErr( - format!("path `{}` is not a SQLite database", path.display()).into(), - )); - } - - let sqlite_page_size = sqlite_page_size_from_header(&header); - let graft_page_size = PAGESIZE.as_usize() as u32; - if sqlite_page_size != graft_page_size { - return Err(ErrCtx::PragmaErr(format!( - "can only read SQLite databases with {graft_page_size}-byte pages directly; \ - `{}` uses {sqlite_page_size}-byte pages. Use VACUUM INTO with the Graft VFS to import it.", - path.display() - ).into())); - } - - let page_size = PAGESIZE.as_usize(); - if metadata.len() % page_size as u64 != 0 { - return Err(ErrCtx::PragmaErr( - format!( - "SQLite database `{}` is not an even multiple of {page_size} bytes", - path.display() - ) - .into(), - )); - } - - let page_count = metadata.len() / page_size as u64; - let page_count = u32::try_from(page_count).map_err(|_| { - ErrCtx::PragmaErr( - format!("SQLite database `{}` has too many pages", path.display()).into(), - ) - })?; - let mut snapshot = graft::snapshot::Snapshot::empty(); - snapshot.page_count = PageCount::new(page_count); - Ok(Self { - input: Mutex::new(input), - path: path.to_path_buf(), - snapshot, - }) - } - - pub(super) fn worktree_state(&self) -> RepoWorktreeFileState { - RepoWorktreeFileState { page_count: self.page_count() } - } - - pub(super) fn matches_state( - &self, - runtime: &Runtime, - expected: &CommitFileState, - ) -> Result { - if self.page_count() != expected.snapshot.page_count { - return Ok(false); - } - - let stored = runtime.snapshot_reader(expected.snapshot.to_snapshot()); - for page_number in 1..=self.page_count().to_u32() { - let pageidx = PageIdx::try_from(page_number).map_err(|err| { - ErrCtx::PragmaErr(format!("invalid SQLite page index {page_number}: {err}").into()) - })?; - if self.read_page(pageidx)? != stored.read_page(pageidx)? { - return Ok(false); - } - } - Ok(true) - } -} - -impl VolumeRead for PhysicalSqliteReader { - fn snapshot(&self) -> &graft::snapshot::Snapshot { - &self.snapshot - } - - fn page_count(&self) -> PageCount { - self.snapshot.page_count - } - - fn read_page(&self, pageidx: PageIdx) -> Result { - if pageidx.to_u32() > self.page_count().to_u32() { - return Ok(Page::EMPTY); - } - let offset = u64::from(pageidx.to_u32() - 1) * PAGESIZE.as_u64(); - let mut page_bytes = vec![0_u8; PAGESIZE.as_usize()]; - let mut input = self.input.lock(); - input.seek(SeekFrom::Start(offset)).map_err(|err| { - graft::err::LogicalErr::Other(format!( - "failed to seek SQLite database `{}`: {err}", - self.path.display() - )) - })?; - input.read_exact(&mut page_bytes).map_err(|err| { - graft::err::LogicalErr::Other(format!( - "failed to read SQLite database `{}`: {err}", - self.path.display() - )) - })?; - Page::try_from(page_bytes.as_slice()).map_err(|err| { - graft::err::LogicalErr::Other(format!( - "invalid SQLite page in `{}`: {err}", - self.path.display() - )) - .into() - }) - } -} - -pub(super) fn physical_sqlite_file_matches_state( - runtime: &Runtime, - path: &Path, - expected: &CommitFileState, -) -> Result { - let physical = PhysicalSqliteReader::open(path)?; - physical.matches_state(runtime, expected) -} - -pub(super) fn import_physical_sqlite_file_state( - runtime: &Runtime, - path: &Path, -) -> Result { - let physical = PhysicalSqliteReader::open(path)?; - let volume = runtime.volume_open(None, None, None)?; - let vid = volume.vid; - let mut writer = runtime.volume_writer(vid.clone())?; - for page_number in 1..=physical.page_count().to_u32() { - let pageidx = PageIdx::try_from(page_number).map_err(|err| { - ErrCtx::PragmaErr( - format!("invalid SQLite page index in `{}`: {err}", path.display()).into(), - ) - })?; - writer.write_page(pageidx, physical.read_page(pageidx)?)?; - } - let reader = writer.commit()?; - Ok(CommitFileState { - volume: vid, - snapshot: repo_snapshot_with_commit_hashes(runtime, reader.snapshot())?, - }) -} - -pub(super) fn sqlite_page_size_from_header(header: &[u8; 100]) -> u32 { - let raw = u16::from_be_bytes([header[16], header[17]]); - if raw == 1 { 65_536 } else { raw as u32 } -} diff --git a/crates/graft-sqlite/src/pragma/repo_switch.rs b/crates/graft-sqlite/src/pragma/repo_switch.rs index 9f5bf19d..e72f3ccc 100644 --- a/crates/graft-sqlite/src/pragma/repo_switch.rs +++ b/crates/graft-sqlite/src/pragma/repo_switch.rs @@ -16,7 +16,7 @@ pub(super) fn run_repo_switch_branch( let previous_files = current_repo_files_for_checkout(&repo)?; let previous_artifacts = current_repo_artifacts_for_checkout(&repo)?; let paths = checkout_plan_path_actions(&plan, &previous_files, &previous_artifacts); - preflight_workspace_checkout(&repo, &plan, &previous_files)?; + let _sqlite_replacement_guards = preflight_workspace_checkout(&repo, &plan, &previous_files)?; repo.apply_switch_branch_plan(&name, &plan)?; checkout_repo_plan( runtime, @@ -47,7 +47,8 @@ pub(super) fn run_repo_switch_create( let previous_files = current_repo_files_for_checkout(&repo)?; let previous_artifacts = current_repo_artifacts_for_checkout(&repo)?; let paths = checkout_plan_path_actions(&plan.checkout, &previous_files, &previous_artifacts); - preflight_workspace_checkout(&repo, &plan.checkout, &previous_files)?; + let _sqlite_replacement_guards = + preflight_workspace_checkout(&repo, &plan.checkout, &previous_files)?; let branch = repo.apply_switch_new_branch_plan(&plan)?; checkout_repo_plan( runtime, diff --git a/crates/graft-sqlite/src/pragma/repo_sync.rs b/crates/graft-sqlite/src/pragma/repo_sync.rs index f7945d00..7ef0de22 100644 --- a/crates/graft-sqlite/src/pragma/repo_sync.rs +++ b/crates/graft-sqlite/src/pragma/repo_sync.rs @@ -110,7 +110,8 @@ pub(super) fn run_repo_pull( ensure_checkout_plan_preserves_untracked_paths(runtime, file, &repo, &plan.merge.checkout)?; let previous_files = current_repo_files_for_checkout(&repo)?; let previous_artifacts = current_repo_artifacts_for_checkout(&repo)?; - preflight_workspace_checkout(&repo, &plan.merge.checkout, &previous_files)?; + let _sqlite_replacement_guards = + preflight_workspace_checkout(&repo, &plan.merge.checkout, &previous_files)?; clear_row_conflict_resolution_state(&repo)?; let mut outcome = repo.apply_pull_plan(&plan)?; checkout_merge_outcome( @@ -129,6 +130,7 @@ pub(super) fn run_repo_pull( &repo, &outcome.merge, Some(checkout_remote), + true, ) { outcome.merge = merge_outcome_with_row_auto_merge(&outcome.merge, &row_auto_merge.key); } diff --git a/crates/graft-sqlite/src/pragma/row_merge_output.rs b/crates/graft-sqlite/src/pragma/row_merge_output.rs index 2df20833..cd01713a 100644 --- a/crates/graft-sqlite/src/pragma/row_merge_output.rs +++ b/crates/graft-sqlite/src/pragma/row_merge_output.rs @@ -11,7 +11,9 @@ pub(super) fn append_row_merge_analysis( let MergeOutcome::Merged { conflicted, .. } = outcome else { return Ok(()); }; - let key = repo.file_key(&file.tag)?; + let Some(key) = selected_repository_database_key(file, repo)? else { + return Ok(()); + }; if !conflicted.iter().any(|path| path == &key) { return Ok(()); } @@ -121,7 +123,9 @@ pub(super) fn current_file_status_row_merge_analysis( repo: &Repository, remote: Option>, ) -> Result, ErrCtx> { - let key = repo.file_key(&file.tag)?; + let Some(key) = selected_repository_database_key(file, repo)? else { + return Ok(None); + }; current_file_row_merge_analysis(runtime, repo, &key, remote) } @@ -134,9 +138,10 @@ pub(super) fn current_file_status_row_merge_analysis_lossy( match current_file_status_row_merge_analysis(runtime, file, repo, remote) { Ok(analysis) => analysis, Err(err) => { - let path = repo - .file_key(&file.tag) - .unwrap_or_else(|_| "db.sqlite3".to_string()); + let path = selected_repository_database_key(file, repo) + .ok() + .flatten() + .unwrap_or_else(|| "db.sqlite3".to_string()); Some(JsonRowMergeAnalysis { path, available: false, @@ -721,16 +726,26 @@ pub(super) fn try_row_auto_merge_current_file_conflict( repo: &Repository, outcome: &MergeOutcome, remote: Option>, + physical_replacement_prepared: bool, ) -> Result, ErrCtx> { let MergeOutcome::Merged { conflicted, .. } = outcome else { return Ok(None); }; - let key = repo.file_key(&file.tag)?; + let Some(key) = selected_repository_database_key(file, repo)? else { + return Ok(None); + }; if !conflicted.iter().any(|path| path == &key) { return Ok(None); } - try_row_merge_current_file_status_conflict(runtime, file, repo, remote, true) + try_row_merge_current_file_status_conflict( + runtime, + file, + repo, + remote, + true, + physical_replacement_prepared, + ) } pub(super) fn try_row_auto_merge_current_file_status_conflict( @@ -739,7 +754,7 @@ pub(super) fn try_row_auto_merge_current_file_status_conflict( repo: &Repository, remote: Option>, ) -> Result, ErrCtx> { - try_row_merge_current_file_status_conflict(runtime, file, repo, remote, false) + try_row_merge_current_file_status_conflict(runtime, file, repo, remote, false, false) } pub(super) fn try_row_merge_current_file_status_conflict( @@ -748,8 +763,11 @@ pub(super) fn try_row_merge_current_file_status_conflict( repo: &Repository, remote: Option>, allow_partial: bool, + physical_replacement_prepared: bool, ) -> Result, ErrCtx> { - let key = repo.file_key(&file.tag)?; + let Some(key) = selected_repository_database_key(file, repo)? else { + return Ok(None); + }; let index = repo.read_index()?; if !index.conflicted_paths().iter().any(|path| path == &key) { return Ok(None); @@ -777,11 +795,18 @@ pub(super) fn try_row_merge_current_file_status_conflict( let applied_changes = plan.apply_change_count(); let sql = plan.theirs_apply_sql(); let merged = materialize_row_auto_merge_state(runtime, repo, &key, &ours, &sql)?; - checkout_repo_file_state(runtime, file, &merged, None)?; + checkout_selected_repository_database( + runtime, + file, + repo, + &key, + &merged, + physical_replacement_prepared, + )?; if plan.analysis.has_conflicts() { return Ok(None); } - repo.resolve_file_conflict(&file.tag, Some(merged))?; + repo.resolve_file_conflict(repo.worktree().join(&key), Some(merged))?; Ok(Some(RowAutoMergeResult { key, @@ -791,6 +816,32 @@ pub(super) fn try_row_merge_current_file_status_conflict( })) } +fn selected_repository_database_key( + file: &VolFile, + repo: &Repository, +) -> Result, ErrCtx> { + file.repository_database_path() + .map(|path| repo.file_key(path).map_err(ErrCtx::from)) + .transpose() +} + +fn checkout_selected_repository_database( + runtime: &Runtime, + file: &mut VolFile, + repo: &Repository, + key: &str, + state: &CommitFileState, + physical_replacement_prepared: bool, +) -> Result<(), ErrCtx> { + if repo.file_key(&file.tag)? == key { + checkout_repo_file_state(runtime, file, state, None) + } else if physical_replacement_prepared { + checkout_repo_file_state_to_prepared_key(runtime, repo, key, state, None) + } else { + checkout_repo_file_state_to_key(runtime, repo, key, state, None) + } +} + pub(super) fn current_file_conflict_states( repo: &Repository, key: &str, @@ -826,7 +877,7 @@ pub(super) fn materialize_row_auto_merge_state( let result = (|| { write_repo_file_state_to_path(runtime, ours, &temp_path)?; apply_row_merge_sql_to_path(&temp_path, sql)?; - import_physical_sqlite_file_state(runtime, &temp_path) + import_stable_sqlite_file_state(runtime, &temp_path) })(); let cleanup = std::fs::remove_file(&temp_path); match (result, cleanup) { diff --git a/crates/graft-sqlite/src/pragma/sqlite_worktree.rs b/crates/graft-sqlite/src/pragma/sqlite_worktree.rs new file mode 100644 index 00000000..1628cc68 --- /dev/null +++ b/crates/graft-sqlite/src/pragma/sqlite_worktree.rs @@ -0,0 +1,671 @@ +use graft::volume_writer::VolumeWriter; +use rusqlite::{Connection, ErrorCode, OpenFlags, backup::Backup}; +use std::time::{Duration, Instant}; +use tempfile::TempDir; + +use super::*; + +/// A stable page reader for a physical `SQLite` worktree file. +/// +/// This module is the data-plane boundary between repository operations and `SQLite`. Repository +/// commands must not read physical `SQLite` pages or manipulate Graft volumes directly. +pub(super) struct PhysicalSqliteReader { + input: Mutex, + path: PathBuf, + snapshot: graft::snapshot::Snapshot, + _snapshot_dir: Option, +} + +impl PhysicalSqliteReader { + pub(super) fn open(path: &Path) -> Result { + validate_sqlite_source(path)?; + + let snapshot_dir = tempfile::Builder::new() + .prefix("graft-sqlite-snapshot-") + .tempdir()?; + let snapshot_path = snapshot_dir.path().join("snapshot.sqlite"); + backup_sqlite_source(path, &snapshot_path)?; + + Self::open_snapshot(path, &snapshot_path, Some(snapshot_dir)) + } + + /// Opens a database whose writer has already been closed and whose bytes are therefore stable. + /// + /// Unlike `SQLite`'s online backup, this preserves page-1 change counters. That matters when an + /// internally generated merge result is rebound to an already-open VFS connection. + fn open_stable(path: &Path) -> Result { + validate_sqlite_source(path)?; + Self::open_snapshot(path, path, None) + } + + fn open_snapshot( + path: &Path, + snapshot_path: &Path, + snapshot_dir: Option, + ) -> Result { + let metadata = std::fs::symlink_metadata(snapshot_path)?; + let mut input = File::open(snapshot_path)?; + let mut header = [0_u8; 100]; + input.read_exact(&mut header)?; + validate_sqlite_header(path, &header)?; + + let page_size = PAGESIZE.as_usize(); + if metadata.len() % page_size as u64 != 0 { + return Err(ErrCtx::PragmaErr( + format!( + "SQLite database `{}` is not an even multiple of {page_size} bytes", + path.display() + ) + .into(), + )); + } + + let page_count = metadata.len() / page_size as u64; + let page_count = u32::try_from(page_count).map_err(|_| { + ErrCtx::PragmaErr( + format!("SQLite database `{}` has too many pages", path.display()).into(), + ) + })?; + let mut snapshot = graft::snapshot::Snapshot::empty(); + snapshot.page_count = PageCount::new(page_count); + Ok(Self { + input: Mutex::new(input), + path: path.to_path_buf(), + snapshot, + _snapshot_dir: snapshot_dir, + }) + } + + pub(super) fn worktree_state(&self) -> RepoWorktreeFileState { + RepoWorktreeFileState { page_count: self.page_count() } + } + + pub(super) fn matches_state( + &self, + runtime: &Runtime, + expected: &CommitFileState, + ) -> Result { + if self.page_count() != expected.snapshot.page_count { + return Ok(false); + } + + let stored = runtime.snapshot_reader(expected.snapshot.to_snapshot()); + for page_number in 1..=self.page_count().to_u32() { + let pageidx = PageIdx::try_from(page_number).map_err(|err| { + ErrCtx::PragmaErr(format!("invalid SQLite page index {page_number}: {err}").into()) + })?; + if self.read_page(pageidx)? != stored.read_page(pageidx)? { + return Ok(false); + } + } + Ok(true) + } +} + +fn validate_sqlite_source(path: &Path) -> Result<(), ErrCtx> { + let metadata = std::fs::symlink_metadata(path)?; + if !metadata.file_type().is_file() { + return Err(ErrCtx::PragmaErr( + format!( + "path `{}` is not a regular SQLite database file", + path.display() + ) + .into(), + )); + } + + if metadata.len() < 100 { + return Err(ErrCtx::PragmaErr( + format!("path `{}` is not a SQLite database", path.display()).into(), + )); + } + + let mut input = File::open(path)?; + let mut header = [0_u8; 100]; + input.read_exact(&mut header)?; + validate_sqlite_header(path, &header) +} + +fn validate_sqlite_header(path: &Path, header: &[u8; 100]) -> Result<(), ErrCtx> { + if &header[..SQLITE_DATABASE_MAGIC.len()] != SQLITE_DATABASE_MAGIC { + return Err(ErrCtx::PragmaErr( + format!("path `{}` is not a SQLite database", path.display()).into(), + )); + } + + let sqlite_page_size = sqlite_page_size_from_header(header); + let graft_page_size = PAGESIZE.as_usize() as u32; + if sqlite_page_size != graft_page_size { + return Err(ErrCtx::PragmaErr(format!( + "cannot store SQLite database `{}`: page size is {sqlite_page_size} bytes, but Graft requires {graft_page_size} bytes", + path.display() + ).into())); + } + Ok(()) +} + +fn backup_sqlite_source(path: &Path, snapshot_path: &Path) -> Result<(), ErrCtx> { + const BACKUP_TIMEOUT: Duration = Duration::from_secs(30); + const BACKUP_RETRY_DELAY: Duration = Duration::from_millis(10); + const PAGES_PER_STEP: i32 = 256; + + let source = Connection::open_with_flags( + path, + OpenFlags::SQLITE_OPEN_READ_ONLY | OpenFlags::SQLITE_OPEN_NO_MUTEX, + )?; + source.busy_timeout(BACKUP_TIMEOUT)?; + let mut destination = Connection::open_with_flags( + snapshot_path, + OpenFlags::SQLITE_OPEN_READ_WRITE + | OpenFlags::SQLITE_OPEN_CREATE + | OpenFlags::SQLITE_OPEN_NO_MUTEX, + )?; + let backup = Backup::new(&source, &mut destination)?; + let deadline = Instant::now() + BACKUP_TIMEOUT; + loop { + match backup.step(PAGES_PER_STEP)? { + rusqlite::backup::StepResult::Done => break, + // Keep copying immediately while progress is being made. Sleeping after every batch + // turns a large, uncontended database into an artificial multi-second import. + rusqlite::backup::StepResult::More => continue, + rusqlite::backup::StepResult::Busy | rusqlite::backup::StepResult::Locked => { + if Instant::now() >= deadline { + return Err(ErrCtx::PragmaErr( + format!( + "timed out waiting for a consistent SQLite snapshot of `{}`", + path.display() + ) + .into(), + )); + } + std::thread::sleep(BACKUP_RETRY_DELAY); + } + _ => unreachable!("unknown SQLite backup step result"), + } + } + drop(backup); + + // A backup of a WAL database retains WAL mode in page 1 even though the destination has no + // WAL or shared-memory sidecars. Normalize the private snapshot so checkout produces a + // standalone database that can also be opened read-only. + let journal_mode: String = + destination.pragma_query_value(None, "journal_mode", |row| row.get(0))?; + if !journal_mode.eq_ignore_ascii_case("delete") { + destination.query_row("PRAGMA journal_mode=DELETE", [], |_| Ok(()))?; + } + Ok(()) +} + +impl VolumeRead for PhysicalSqliteReader { + fn snapshot(&self) -> &graft::snapshot::Snapshot { + &self.snapshot + } + + fn page_count(&self) -> PageCount { + self.snapshot.page_count + } + + fn read_page(&self, pageidx: PageIdx) -> Result { + if pageidx.to_u32() > self.page_count().to_u32() { + return Ok(Page::EMPTY); + } + let offset = u64::from(pageidx.to_u32() - 1) * PAGESIZE.as_u64(); + let mut page_bytes = vec![0_u8; PAGESIZE.as_usize()]; + let mut input = self.input.lock(); + input.seek(SeekFrom::Start(offset)).map_err(|err| { + graft::err::LogicalErr::Other(format!( + "failed to seek SQLite database `{}`: {err}", + self.path.display() + )) + })?; + input.read_exact(&mut page_bytes).map_err(|err| { + graft::err::LogicalErr::Other(format!( + "failed to read SQLite database `{}`: {err}", + self.path.display() + )) + })?; + Page::try_from(page_bytes.as_slice()).map_err(|err| { + graft::err::LogicalErr::Other(format!( + "invalid SQLite page in `{}`: {err}", + self.path.display() + )) + .into() + }) + } +} + +pub(super) fn physical_sqlite_file_matches_state( + runtime: &Runtime, + path: &Path, + expected: &CommitFileState, +) -> Result { + let physical = PhysicalSqliteReader::open(path)?; + physical.matches_state(runtime, expected) +} + +/// Prepares an existing worktree database for atomic replacement. +/// +/// Physical `SQLite` files are outside Graft's VFS lock manager. We therefore ask `SQLite` for an +/// exclusive lock, fold a stale WAL into the main database, switch the old file back to rollback +/// journal mode, and remove sidecars before rename. A live writer causes the checkout to fail +/// instead of replacing the main file underneath it. +pub(super) struct SqliteReplacementGuard { + connection: Option, +} + +impl Drop for SqliteReplacementGuard { + fn drop(&mut self) { + if let Some(connection) = self.connection.take() { + let _ = connection.execute_batch("ROLLBACK"); + } + } +} + +pub(super) fn prepare_sqlite_path_for_replacement( + path: &Path, +) -> Result { + const LOCK_TIMEOUT: Duration = Duration::from_secs(1); + + if !path.exists() { + remove_sqlite_sidecars(path)?; + return Ok(SqliteReplacementGuard { connection: None }); + } + if !is_sqlite_database_path(path)? { + return Ok(SqliteReplacementGuard { connection: None }); + } + + let connection = Connection::open_with_flags( + path, + OpenFlags::SQLITE_OPEN_READ_WRITE | OpenFlags::SQLITE_OPEN_NO_MUTEX, + )?; + connection.busy_timeout(LOCK_TIMEOUT)?; + match connection.execute_batch("BEGIN EXCLUSIVE; ROLLBACK;") { + Ok(()) => {} + Err(rusqlite::Error::SqliteFailure(error, _)) + if matches!( + error.code, + ErrorCode::DatabaseBusy | ErrorCode::DatabaseLocked + ) => + { + return Err(ErrCtx::PragmaErr( + format!( + "cannot replace SQLite database `{}` while another transaction is active", + path.display() + ) + .into(), + )); + } + Err(error) => return Err(error.into()), + } + + let journal_mode: String = + connection.pragma_query_value(None, "journal_mode", |row| row.get(0))?; + if journal_mode.eq_ignore_ascii_case("wal") { + let (busy, log_pages, checkpointed_pages): (i64, i64, i64) = + connection.query_row("PRAGMA wal_checkpoint(TRUNCATE)", [], |row| { + Ok((row.get(0)?, row.get(1)?, row.get(2)?)) + })?; + if busy != 0 || log_pages != checkpointed_pages { + return Err(ErrCtx::PragmaErr( + format!( + "cannot replace SQLite database `{}` while its WAL is in use", + path.display() + ) + .into(), + )); + } + let normalized: String = + connection.query_row("PRAGMA journal_mode=DELETE", [], |row| row.get(0))?; + if !normalized.eq_ignore_ascii_case("delete") { + return Err(ErrCtx::PragmaErr( + format!( + "could not detach WAL before replacing SQLite database `{}`", + path.display() + ) + .into(), + )); + } + } + match connection.execute_batch("BEGIN EXCLUSIVE") { + Ok(()) => {} + Err(rusqlite::Error::SqliteFailure(error, _)) + if matches!( + error.code, + ErrorCode::DatabaseBusy | ErrorCode::DatabaseLocked + ) => + { + return Err(ErrCtx::PragmaErr( + format!( + "cannot replace SQLite database `{}` while another transaction is active", + path.display() + ) + .into(), + )); + } + Err(error) => return Err(error.into()), + } + remove_sqlite_sidecars(path)?; + Ok(SqliteReplacementGuard { connection: Some(connection) }) +} + +fn remove_sqlite_sidecars(path: &Path) -> Result<(), ErrCtx> { + for suffix in ["-wal", "-shm", "-journal"] { + let mut sidecar = path.as_os_str().to_os_string(); + sidecar.push(suffix); + let sidecar = PathBuf::from(sidecar); + match std::fs::symlink_metadata(&sidecar) { + Ok(metadata) if metadata.file_type().is_file() => std::fs::remove_file(sidecar)?, + Ok(_) => { + return Err(ErrCtx::PragmaErr( + format!( + "SQLite sidecar `{}` is not a regular file", + sidecar.display() + ) + .into(), + )); + } + Err(error) if error.kind() == std::io::ErrorKind::NotFound => {} + Err(error) => return Err(error.into()), + } + } + Ok(()) +} + +/// Imports a physical `SQLite` worktree file into Graft storage. +/// +/// When a staged or committed base exists, unchanged pages remain referenced by the base snapshot +/// and only changed pages produce a new storage commit. The committed repository representation is +/// unchanged: callers still receive a `CommitFileState` containing a Volume and immutable snapshot. +pub(super) fn import_physical_sqlite_file_state( + runtime: &Runtime, + path: &Path, + base: Option<&CommitFileState>, +) -> Result { + let physical = PhysicalSqliteReader::open(path)?; + import_sqlite_reader_state(runtime, path, base, physical) +} + +pub(super) fn import_stable_sqlite_file_state( + runtime: &Runtime, + path: &Path, +) -> Result { + let physical = PhysicalSqliteReader::open_stable(path)?; + import_sqlite_reader_state(runtime, path, None, physical) +} + +fn import_sqlite_reader_state( + runtime: &Runtime, + path: &Path, + base: Option<&CommitFileState>, + physical: PhysicalSqliteReader, +) -> Result { + let base_reader = base.map(|state| runtime.snapshot_reader(state.snapshot.to_snapshot())); + let mut target = None; + + for page_number in 1..=physical.page_count().to_u32() { + let pageidx = PageIdx::try_from(page_number).map_err(|err| { + ErrCtx::PragmaErr( + format!("invalid SQLite page index in `{}`: {err}", path.display()).into(), + ) + })?; + let page = physical.read_page(pageidx)?; + let unchanged = match &base_reader { + Some(reader) if reader.page_count().contains(pageidx) => { + reader.read_page(pageidx)? == page + } + _ => false, + }; + if unchanged { + continue; + } + + let target = ensure_import_target(runtime, base, &mut target)?; + target.writer.write_page(pageidx, page)?; + } + + if base.is_none_or(|state| state.snapshot.page_count != physical.page_count()) { + let target = ensure_import_target(runtime, base, &mut target)?; + target.writer.soft_truncate(physical.page_count())?; + } + + let Some(target) = target else { + return Ok(base + .cloned() + .expect("an unchanged import must have a base snapshot")); + }; + target.commit(runtime) +} + +struct ImportTarget { + vid: VolumeId, + writer: VolumeWriter, + cleanup_on_error: bool, +} + +impl ImportTarget { + fn open(runtime: &Runtime, base: Option<&CommitFileState>) -> Result { + if let Some(base) = base { + let snapshot = base.snapshot.to_snapshot(); + if runtime.volume_exists(&base.volume)? + && runtime.snapshot_is_latest(&base.volume, &snapshot)? + { + return Ok(Self { + vid: base.volume.clone(), + writer: runtime.volume_writer(base.volume.clone())?, + cleanup_on_error: false, + }); + } + + let volume = runtime.volume_from_snapshot(&snapshot)?; + return Ok(Self { + writer: runtime.volume_writer(volume.vid.clone())?, + vid: volume.vid, + cleanup_on_error: true, + }); + } + + let volume = runtime.volume_open(None, None, None)?; + Ok(Self { + writer: runtime.volume_writer(volume.vid.clone())?, + vid: volume.vid, + cleanup_on_error: true, + }) + } + + fn commit(mut self, runtime: &Runtime) -> Result { + let writer = self.writer; + let reader = match writer.commit() { + Ok(reader) => reader, + Err(err) => { + if self.cleanup_on_error { + let _ = runtime.volume_delete(&self.vid); + } + return Err(err.into()); + } + }; + self.cleanup_on_error = false; + Ok(CommitFileState { + volume: self.vid, + snapshot: repo_snapshot_with_commit_hashes(runtime, reader.snapshot())?, + }) + } +} + +fn ensure_import_target<'a>( + runtime: &Runtime, + base: Option<&CommitFileState>, + target: &'a mut Option, +) -> Result<&'a mut ImportTarget, ErrCtx> { + if target.is_none() { + *target = Some(ImportTarget::open(runtime, base)?); + } + Ok(target.as_mut().expect("import target was initialized")) +} + +pub(super) fn sqlite_page_size_from_header(header: &[u8; 100]) -> u32 { + let raw = u16::from_be_bytes([header[16], header[17]]); + if raw == 1 { 65_536 } else { raw as u32 } +} + +#[cfg(all(test, not(target_arch = "wasm32")))] +mod tests { + use graft::setup::setup_graft_temporary; + use rusqlite::params; + + use super::*; + + fn test_runtime() -> Runtime { + setup_graft_temporary(RemoteConfig::Memory, None).unwrap() + } + + fn create_database(path: &Path, journal_mode: &str) -> Connection { + let mut connection = Connection::open(path).unwrap(); + connection + .pragma_update(None, "page_size", PAGESIZE.as_u32()) + .unwrap(); + let actual_mode: String = connection + .pragma_query_value(None, "journal_mode", |row| row.get(0)) + .unwrap(); + if !actual_mode.eq_ignore_ascii_case(journal_mode) { + let sql = format!("PRAGMA journal_mode={journal_mode}"); + connection.query_row(&sql, [], |_| Ok(())).unwrap(); + } + if journal_mode.eq_ignore_ascii_case("wal") { + connection + .pragma_update(None, "wal_autocheckpoint", 0) + .unwrap(); + } + connection + .execute_batch("CREATE TABLE records(id INTEGER PRIMARY KEY, payload BLOB NOT NULL);") + .unwrap(); + let transaction = connection.transaction().unwrap(); + for id in 1..=64_i64 { + transaction + .execute( + "INSERT INTO records(id, payload) VALUES (?1, ?2)", + params![id, vec![id as u8; 3_000]], + ) + .unwrap(); + } + transaction.commit().unwrap(); + connection + } + + #[test] + fn unchanged_import_reuses_snapshot_and_changed_import_is_incremental() { + let temp = tempfile::tempdir().unwrap(); + let path = temp.path().join("app.sqlite"); + let connection = create_database(&path, "delete"); + let runtime = test_runtime(); + + let initial = import_physical_sqlite_file_state(&runtime, &path, None).unwrap(); + let commits_before = runtime.volume_log(&initial.volume).unwrap().len(); + let unchanged = import_physical_sqlite_file_state(&runtime, &path, Some(&initial)).unwrap(); + assert_eq!(unchanged, initial); + assert_eq!( + runtime.volume_log(&initial.volume).unwrap().len(), + commits_before + ); + + connection + .execute( + "UPDATE records SET payload = ?1 WHERE id = 32", + [vec![0xA5_u8; 3_000]], + ) + .unwrap(); + let updated = import_physical_sqlite_file_state(&runtime, &path, Some(&initial)).unwrap(); + assert_eq!(updated.volume, initial.volume); + assert_ne!(updated.snapshot, initial.snapshot); + + let latest = runtime.volume_log(&updated.volume).unwrap().remove(0); + assert!(latest.changed_pages > 0); + assert!(latest.changed_pages < updated.snapshot.page_count.to_u32() as usize); + } + + #[test] + fn wal_import_reads_committed_state_without_checkpointing_source() { + let temp = tempfile::tempdir().unwrap(); + let path = temp.path().join("app.sqlite"); + let connection = create_database(&path, "wal"); + let wal_path = PathBuf::from(format!("{}-wal", path.display())); + assert!(wal_path.exists()); + let runtime = test_runtime(); + + let initial = import_physical_sqlite_file_state(&runtime, &path, None).unwrap(); + connection + .execute( + "UPDATE records SET payload = ?1 WHERE id = 17", + [vec![0x5A_u8; 3_000]], + ) + .unwrap(); + let updated = import_physical_sqlite_file_state(&runtime, &path, Some(&initial)).unwrap(); + assert!( + wal_path.exists(), + "import must not checkpoint or remove the source WAL" + ); + + let materialized = temp.path().join("materialized.sqlite"); + write_repo_file_state_to_path(&runtime, &updated, &materialized).unwrap(); + let restored = + Connection::open_with_flags(&materialized, OpenFlags::SQLITE_OPEN_READ_ONLY).unwrap(); + let payload: Vec = restored + .query_row("SELECT payload FROM records WHERE id = 17", [], |row| { + row.get(0) + }) + .unwrap(); + assert_eq!(payload, vec![0x5A_u8; 3_000]); + } + + #[test] + fn materialization_refuses_a_live_writer_and_cleans_stale_sidecars() { + let temp = tempfile::tempdir().unwrap(); + let path = temp.path().join("app.sqlite"); + let connection = create_database(&path, "wal"); + let runtime = test_runtime(); + let state = import_physical_sqlite_file_state(&runtime, &path, None).unwrap(); + + connection.execute_batch("BEGIN IMMEDIATE").unwrap(); + let error = write_repo_file_state_to_path(&runtime, &state, &path).unwrap_err(); + assert!( + error + .to_string() + .contains("while another transaction is active"), + "{error}" + ); + connection.execute_batch("ROLLBACK").unwrap(); + drop(connection); + + write_repo_file_state_to_path(&runtime, &state, &path).unwrap(); + for suffix in ["-wal", "-shm", "-journal"] { + assert!(!PathBuf::from(format!("{}{}", path.display(), suffix)).exists()); + } + Connection::open_with_flags(&path, OpenFlags::SQLITE_OPEN_READ_ONLY).unwrap(); + } + + #[test] + fn replacement_guard_blocks_new_writers_until_it_is_dropped() { + let temp = tempfile::tempdir().unwrap(); + let path = temp.path().join("app.sqlite"); + drop(create_database(&path, "delete")); + + let guard = prepare_sqlite_path_for_replacement(&path).unwrap(); + let contender = Connection::open(&path).unwrap(); + contender.busy_timeout(Duration::ZERO).unwrap(); + let error = contender.execute_batch("BEGIN IMMEDIATE").unwrap_err(); + assert!(matches!( + error, + rusqlite::Error::SqliteFailure( + rusqlite::ffi::Error { + code: ErrorCode::DatabaseBusy | ErrorCode::DatabaseLocked, + .. + }, + _ + ) + )); + + drop(guard); + contender + .execute_batch("BEGIN IMMEDIATE; ROLLBACK") + .unwrap(); + } +} diff --git a/crates/graft-sqlite/src/pragma/tests.rs b/crates/graft-sqlite/src/pragma/tests.rs index b144a0e3..40a4bbad 100644 --- a/crates/graft-sqlite/src/pragma/tests.rs +++ b/crates/graft-sqlite/src/pragma/tests.rs @@ -4,21 +4,27 @@ use graft::repo::{RepoConflictChange, RepoStagedChange, RepoStatusCounts, RepoWo #[test] fn legacy_volume_pragmas_are_debug_only() { let legacy = Pragma { name: "graft_volume_push", arg: None }; - assert!(GraftPragma::try_from(&legacy).is_err()); + assert!(VfsPragma::try_from(&legacy).is_err()); let debug = Pragma { name: "graft_debug_volume_push", arg: None, }; assert!(matches!( - GraftPragma::try_from(&debug).unwrap(), - GraftPragma::VolumePush + VfsPragma::try_from(&debug).unwrap().0, + GraftCommand::VolumePush )); } #[test] -fn undocumented_repo_compat_pragmas_are_rejected() { +fn repository_commands_are_not_exposed_as_sqlite_pragmas() { for name in [ + "graft_status", + "graft_json_status", + "graft_add", + "graft_json_commit", + "graft_checkout", + "graft_fetch", "graft_repo_status", "graft_remove", "graft_branch_move", @@ -30,57 +36,15 @@ fn undocumented_repo_compat_pragmas_are_rejected() { ] { let pragma = Pragma { name, arg: Some("app.db") }; assert!( - GraftPragma::try_from(&pragma).is_err(), + VfsPragma::try_from(&pragma).is_err(), "{name} should be rejected" ); } let status = Pragma { name: "graft_status", arg: None }; assert!(matches!( - GraftPragma::try_from(&status).unwrap(), - GraftPragma::Status { spec: StatusSpec { kind: None } } - )); - let json_status = Pragma { - name: "graft_json_status", - arg: Some("--kind sqlite"), - }; - assert!(matches!( - GraftPragma::try_from(&json_status).unwrap(), - GraftPragma::JsonStatus { - spec: StatusSpec { - kind: Some(RepoTrackedPathKind::SqliteDatabase), - }, - } - )); - - let json_init = Pragma { name: "graft_json_init", arg: None }; - assert!(matches!( - GraftPragma::try_from(&json_init).unwrap(), - GraftPragma::JsonRepoInit { .. } - )); - - let remove = Pragma { name: "graft_rm", arg: Some("app.db") }; - assert!(matches!( - GraftPragma::try_from(&remove).unwrap(), - GraftPragma::Remove { .. } - )); - - let json_remove = Pragma { - name: "graft_json_rm", - arg: Some("app.db"), - }; - assert!(matches!( - GraftPragma::try_from(&json_remove).unwrap(), - GraftPragma::JsonRemove { .. } - )); - - let json_commit = Pragma { - name: "graft_json_commit", - arg: Some("message"), - }; - assert!(matches!( - GraftPragma::try_from(&json_commit).unwrap(), - GraftPragma::JsonCommit { .. } + GraftCommand::parse(&status).unwrap(), + GraftCommand::Status { spec: StatusSpec { kind: None } } )); } @@ -88,8 +52,8 @@ fn undocumented_repo_compat_pragmas_are_rejected() { fn json_log_status_mode_is_opt_in() { let legacy = Pragma { name: "graft_json_log", arg: None }; assert!(matches!( - GraftPragma::try_from(&legacy).unwrap(), - GraftPragma::JsonLog { + GraftCommand::parse(&legacy).unwrap(), + GraftCommand::JsonLog { spec: JsonLogSpec { mode: JsonLogMode::LegacyArray, limit: None, @@ -103,8 +67,8 @@ fn json_log_status_mode_is_opt_in() { arg: Some("--with-status"), }; assert!(matches!( - GraftPragma::try_from(&with_status).unwrap(), - GraftPragma::JsonLog { + GraftCommand::parse(&with_status).unwrap(), + GraftCommand::JsonLog { spec: JsonLogSpec { mode: JsonLogMode::WithStatus, limit: None, @@ -118,8 +82,8 @@ fn json_log_status_mode_is_opt_in() { arg: Some("--with-status --limit 25 --after abc123"), }; assert!(matches!( - GraftPragma::try_from(&page).unwrap(), - GraftPragma::JsonLog { + GraftCommand::parse(&page).unwrap(), + GraftCommand::JsonLog { spec: JsonLogSpec { mode: JsonLogMode::WithStatus, limit: Some(25), @@ -132,13 +96,13 @@ fn json_log_status_mode_is_opt_in() { name: "graft_json_log", arg: Some("--status"), }; - assert!(GraftPragma::try_from(&invalid).is_err()); + assert!(GraftCommand::parse(&invalid).is_err()); let cursor_without_limit = Pragma { name: "graft_json_log", arg: Some("--after abc123"), }; - assert!(GraftPragma::try_from(&cursor_without_limit).is_err()); + assert!(GraftCommand::parse(&cursor_without_limit).is_err()); } #[test] @@ -148,8 +112,8 @@ fn json_config_list_status_mode_is_opt_in() { arg: None, }; assert!(matches!( - GraftPragma::try_from(&legacy).unwrap(), - GraftPragma::JsonConfigList { mode: JsonConfigListMode::LegacyArray } + GraftCommand::parse(&legacy).unwrap(), + GraftCommand::JsonConfigList { mode: JsonConfigListMode::LegacyArray } )); let with_status = Pragma { @@ -157,23 +121,23 @@ fn json_config_list_status_mode_is_opt_in() { arg: Some("--with-status"), }; assert!(matches!( - GraftPragma::try_from(&with_status).unwrap(), - GraftPragma::JsonConfigList { mode: JsonConfigListMode::WithStatus } + GraftCommand::parse(&with_status).unwrap(), + GraftCommand::JsonConfigList { mode: JsonConfigListMode::WithStatus } )); let invalid = Pragma { name: "graft_json_config_list", arg: Some("--status"), }; - assert!(GraftPragma::try_from(&invalid).is_err()); + assert!(GraftCommand::parse(&invalid).is_err()); } #[test] fn json_tags_status_mode_is_opt_in() { let legacy = Pragma { name: "graft_json_tags", arg: None }; assert!(matches!( - GraftPragma::try_from(&legacy).unwrap(), - GraftPragma::JsonTags { mode: JsonTagsMode::LegacyArray } + GraftCommand::parse(&legacy).unwrap(), + GraftCommand::JsonTags { mode: JsonTagsMode::LegacyArray } )); let with_status = Pragma { @@ -181,15 +145,15 @@ fn json_tags_status_mode_is_opt_in() { arg: Some("--with-status"), }; assert!(matches!( - GraftPragma::try_from(&with_status).unwrap(), - GraftPragma::JsonTags { mode: JsonTagsMode::WithStatus } + GraftCommand::parse(&with_status).unwrap(), + GraftCommand::JsonTags { mode: JsonTagsMode::WithStatus } )); let invalid = Pragma { name: "graft_json_tags", arg: Some("--status"), }; - assert!(GraftPragma::try_from(&invalid).is_err()); + assert!(GraftCommand::parse(&invalid).is_err()); } #[test] @@ -199,8 +163,8 @@ fn async_job_pragmas_are_parsed() { arg: Some("--all origin"), }; assert!(matches!( - GraftPragma::try_from(&fetch).unwrap(), - GraftPragma::FetchAsync { remote: Some(_), all: true, .. } + GraftCommand::parse(&fetch).unwrap(), + GraftCommand::FetchAsync { remote: Some(_), all: true, .. } )); let json_fetch = Pragma { @@ -208,8 +172,8 @@ fn async_job_pragmas_are_parsed() { arg: Some("origin main"), }; assert!(matches!( - GraftPragma::try_from(&json_fetch).unwrap(), - GraftPragma::JsonFetchAsync { + GraftCommand::parse(&json_fetch).unwrap(), + GraftCommand::JsonFetchAsync { remote: Some(_), branch: Some(_), mode: JsonFetchAsyncMode::LegacyId, @@ -222,8 +186,8 @@ fn async_job_pragmas_are_parsed() { arg: Some("--with-status --all origin"), }; assert!(matches!( - GraftPragma::try_from(&json_fetch_with_status).unwrap(), - GraftPragma::JsonFetchAsync { + GraftCommand::parse(&json_fetch_with_status).unwrap(), + GraftCommand::JsonFetchAsync { remote: Some(_), all: true, mode: JsonFetchAsyncMode::WithStatus, @@ -236,8 +200,8 @@ fn async_job_pragmas_are_parsed() { arg: Some("graft-job-1"), }; assert!(matches!( - GraftPragma::try_from(&status).unwrap(), - GraftPragma::JobStatus { .. } + GraftCommand::parse(&status).unwrap(), + GraftCommand::JobStatus { .. } )); let json_status = Pragma { @@ -245,8 +209,8 @@ fn async_job_pragmas_are_parsed() { arg: Some("graft-job-1"), }; assert!(matches!( - GraftPragma::try_from(&json_status).unwrap(), - GraftPragma::JsonJobStatus { .. } + GraftCommand::parse(&json_status).unwrap(), + GraftCommand::JsonJobStatus { .. } )); let result = Pragma { @@ -254,8 +218,8 @@ fn async_job_pragmas_are_parsed() { arg: Some("graft-job-1"), }; assert!(matches!( - GraftPragma::try_from(&result).unwrap(), - GraftPragma::JobResult { .. } + GraftCommand::parse(&result).unwrap(), + GraftCommand::JobResult { .. } )); } @@ -287,29 +251,88 @@ fn parse_remote_add_rejects_unknown_s3_query_parameters() { } #[test] -fn parse_remote_add_supports_graft_http_remote() { - let (name, config) = parse_remote_add( - "origin graft+https://graft.example.com/api/graft/v1/repos/acme/app?token_env=GRAFT_TOKEN", - ) - .unwrap(); +fn parse_remote_add_supports_canonical_https_remote() { + let (name, config) = + parse_remote_add("origin https://graft.example.com/acme/app?token_env=GRAFT_TOKEN") + .unwrap(); assert_eq!(name, "origin"); assert_eq!( config, RemoteConfig::Http { - url: "https://graft.example.com/api/graft/v1/repos/acme/app".to_string(), + url: "https://graft.example.com/acme/app".to_string(), token_env: Some("GRAFT_TOKEN".to_string()), } ); assert_eq!( remote_config_uri(&config), - "graft+https://graft.example.com/api/graft/v1/repos/acme/app?token_env=GRAFT_TOKEN" + "https://graft.example.com/acme/app?token_env=GRAFT_TOKEN" ); } #[test] -fn parse_remote_add_rejects_unknown_graft_http_query_parameters() { - assert!(parse_remote_add("origin graft+https://graft.example.com/api?token=secret").is_err()); +fn parse_remote_add_canonicalizes_https_alias_and_preserves_local_http_alias() { + let (_, https) = parse_remote_add("origin graft+https://graft.example.com/acme/app").unwrap(); + assert_eq!( + remote_config_uri(&https), + "https://graft.example.com/acme/app" + ); + + let (_, legacy) = + parse_remote_add("origin graft+https://graft.example.com/api/graft/v1/repos/acme/app") + .unwrap(); + assert_eq!( + remote_config_uri(&legacy), + "https://graft.example.com/api/graft/v1/repos/acme/app" + ); + + let (_, http) = parse_remote_add("origin graft+http://127.0.0.1:8787/acme/app").unwrap(); + assert_eq!( + http, + RemoteConfig::Http { + url: "http://127.0.0.1:8787/acme/app".to_string(), + token_env: None, + } + ); + assert_eq!( + remote_config_uri(&http), + "graft+http://127.0.0.1:8787/acme/app" + ); + + let (_, ipv6) = parse_remote_add("origin graft+http://[::1]:8787/acme/app").unwrap(); + assert_eq!(remote_config_uri(&ipv6), "graft+http://[::1]:8787/acme/app"); +} + +#[test] +fn parse_remote_add_rejects_invalid_http_remote_uris() { + for uri in [ + "http://graft.example.com/acme/app", + "https://graft.example.com", + "https://graft.example.com/", + "https:///acme/app", + "https://:/acme/app", + "https://graft.example.com:+443/acme/app", + "https://graft.example.com:invalid/acme/app", + "https://[not-ipv6]/acme/app", + "https://user@graft.example.com/acme/app", + "https://graft.example.com/acme//app", + "https://graft.example.com/acme/app/", + "https://graft.example.com/acme\\..\\app", + "https://graft.example.com/acme/./app", + "https://graft.example.com/acme/../app", + "https://graft.example.com/acme/%2e%2e/app", + "https://graft.example.com/acme/app#readme", + "https://graft.example.com/acme/app?", + "https://graft.example.com/acme/app?token_env=", + "https://graft.example.com/acme/app?token_env=ONE&token_env=TWO", + "https://graft.example.com/acme/app?token=secret", + "https://graft.example.com/acme/app?token_env=TOKEN&", + ] { + assert!( + parse_remote_add(&format!("origin {uri}")).is_err(), + "URI should be rejected: {uri}" + ); + } } #[test] @@ -329,8 +352,8 @@ fn parse_json_remote_pragmas() { arg: Some("origin memory"), }; assert!(matches!( - GraftPragma::try_from(&add).unwrap(), - GraftPragma::JsonRemoteAdd { name, config: RemoteConfig::Memory } if name == "origin" + GraftCommand::parse(&add).unwrap(), + GraftCommand::JsonRemoteAdd { name, config: RemoteConfig::Memory } if name == "origin" )); let remove = Pragma { @@ -338,8 +361,8 @@ fn parse_json_remote_pragmas() { arg: Some("origin"), }; assert!(matches!( - GraftPragma::try_from(&remove).unwrap(), - GraftPragma::JsonRemoteRemove { name } if name == "origin" + GraftCommand::parse(&remove).unwrap(), + GraftCommand::JsonRemoteRemove { name } if name == "origin" )); let rename = Pragma { @@ -347,8 +370,8 @@ fn parse_json_remote_pragmas() { arg: Some("origin upstream"), }; assert!(matches!( - GraftPragma::try_from(&rename).unwrap(), - GraftPragma::JsonRemoteRename { old, new } if old == "origin" && new == "upstream" + GraftCommand::parse(&rename).unwrap(), + GraftCommand::JsonRemoteRename { old, new } if old == "origin" && new == "upstream" )); let get_url = Pragma { @@ -356,8 +379,8 @@ fn parse_json_remote_pragmas() { arg: Some("origin"), }; assert!(matches!( - GraftPragma::try_from(&get_url).unwrap(), - GraftPragma::JsonRemoteGetUrl { name } if name == "origin" + GraftCommand::parse(&get_url).unwrap(), + GraftCommand::JsonRemoteGetUrl { name } if name == "origin" )); let set_url = Pragma { @@ -365,8 +388,8 @@ fn parse_json_remote_pragmas() { arg: Some("origin memory"), }; assert!(matches!( - GraftPragma::try_from(&set_url).unwrap(), - GraftPragma::JsonRemoteSetUrl { name, config: RemoteConfig::Memory } if name == "origin" + GraftCommand::parse(&set_url).unwrap(), + GraftCommand::JsonRemoteSetUrl { name, config: RemoteConfig::Memory } if name == "origin" )); let prune = Pragma { @@ -374,8 +397,8 @@ fn parse_json_remote_pragmas() { arg: Some("origin"), }; assert!(matches!( - GraftPragma::try_from(&prune).unwrap(), - GraftPragma::JsonRemotePrune { name } if name == "origin" + GraftCommand::parse(&prune).unwrap(), + GraftCommand::JsonRemotePrune { name } if name == "origin" )); let ls_remote = Pragma { @@ -383,14 +406,14 @@ fn parse_json_remote_pragmas() { arg: Some("origin"), }; assert!(matches!( - GraftPragma::try_from(&ls_remote).unwrap(), - GraftPragma::JsonLsRemote { name } if name == "origin" + GraftCommand::parse(&ls_remote).unwrap(), + GraftCommand::JsonLsRemote { name } if name == "origin" )); let remotes = Pragma { name: "graft_json_remotes", arg: None }; assert!(matches!( - GraftPragma::try_from(&remotes).unwrap(), - GraftPragma::JsonRemotes + GraftCommand::parse(&remotes).unwrap(), + GraftCommand::JsonRemotes )); } @@ -467,8 +490,8 @@ fn parse_repo_clone_arg_supports_default_branch_and_branch_flags() { arg: Some("--branch feature/search memory"), }; assert!(matches!( - GraftPragma::try_from(&json_clone).unwrap(), - GraftPragma::JsonRepoClone { + GraftCommand::parse(&json_clone).unwrap(), + GraftCommand::JsonRepoClone { spec: RepoCloneSpec { config: RemoteConfig::Memory, branch: Some(branch), @@ -1003,40 +1026,40 @@ fn parse_storage_gc_arg_defaults_to_dry_run() { assert!(parse_storage_gc_arg(Some("--unknown")).is_err()); assert!(matches!( - GraftPragma::try_from(&Pragma { + GraftCommand::parse(&Pragma { name: "graft_json_gc", arg: Some("--force") }) .unwrap(), - GraftPragma::JsonStorageGc { spec: StorageGcSpec { dry_run: false } } + GraftCommand::JsonStorageGc { spec: StorageGcSpec { dry_run: false } } )); } #[test] fn payload_pragmas_alias_lfs_payload_pragmas() { assert!(matches!( - GraftPragma::try_from(&Pragma { + GraftCommand::parse(&Pragma { name: "graft_payload_fetch", arg: Some("--remote origin HEAD") }) .unwrap(), - GraftPragma::LargeFileFetch { .. } + GraftCommand::LargeFileFetch { .. } )); assert!(matches!( - GraftPragma::try_from(&Pragma { + GraftCommand::parse(&Pragma { name: "graft_json_payload_status", arg: Some("HEAD") }) .unwrap(), - GraftPragma::JsonLargeFileStatus { .. } + GraftCommand::JsonLargeFileStatus { .. } )); assert!(matches!( - GraftPragma::try_from(&Pragma { + GraftCommand::parse(&Pragma { name: "graft_payload_prune", arg: Some("--dry-run") }) .unwrap(), - GraftPragma::LargeFilePrune { .. } + GraftCommand::LargeFilePrune { .. } )); } @@ -1334,8 +1357,8 @@ fn parse_json_tag_pragmas() { arg: Some("v1.0 HEAD"), }; assert!(matches!( - GraftPragma::try_from(&create).unwrap(), - GraftPragma::JsonTagCreate { + GraftCommand::parse(&create).unwrap(), + GraftCommand::JsonTagCreate { name, target: Some(target), message: None, @@ -1347,8 +1370,8 @@ fn parse_json_tag_pragmas() { arg: Some("--annotated v1.0 HEAD -- release 1.0"), }; assert!(matches!( - GraftPragma::try_from(&annotated).unwrap(), - GraftPragma::JsonTagCreate { + GraftCommand::parse(&annotated).unwrap(), + GraftCommand::JsonTagCreate { name, target: Some(target), message: Some(message), @@ -1360,8 +1383,8 @@ fn parse_json_tag_pragmas() { arg: Some("v1.0"), }; assert!(matches!( - GraftPragma::try_from(&delete).unwrap(), - GraftPragma::JsonTagDelete { name } if name == "v1.0" + GraftCommand::parse(&delete).unwrap(), + GraftCommand::JsonTagDelete { name } if name == "v1.0" )); } @@ -1534,8 +1557,8 @@ fn parse_checkout_and_switch_force_args() { arg: Some("--source HEAD --output snapshot.db -- app.db"), }; assert!(matches!( - GraftPragma::try_from(&json_export).unwrap(), - GraftPragma::JsonExport { + GraftCommand::parse(&json_export).unwrap(), + GraftCommand::JsonExport { spec: RepoExportSpec { source: Some(source), path: Some(path), @@ -1579,16 +1602,16 @@ fn parse_checkout_and_switch_force_args() { arg: Some("--force main"), }; assert!(matches!( - GraftPragma::try_from(&json_switch_branch).unwrap(), - GraftPragma::JsonSwitchBranch { name, force: true } if name == "main" + GraftCommand::parse(&json_switch_branch).unwrap(), + GraftCommand::JsonSwitchBranch { name, force: true } if name == "main" )); let json_switch_create = Pragma { name: "graft_json_switch_create", arg: Some("-f feature/search HEAD"), }; assert!(matches!( - GraftPragma::try_from(&json_switch_create).unwrap(), - GraftPragma::JsonSwitchCreate { + GraftCommand::parse(&json_switch_create).unwrap(), + GraftCommand::JsonSwitchCreate { name, start_point: Some(start_point), force: true, @@ -1599,8 +1622,8 @@ fn parse_checkout_and_switch_force_args() { arg: Some("feature/search HEAD"), }; assert!(matches!( - GraftPragma::try_from(&json_branch_create).unwrap(), - GraftPragma::JsonBranchCreate { + GraftCommand::parse(&json_branch_create).unwrap(), + GraftCommand::JsonBranchCreate { name, start_point: Some(start_point), } if name == "feature/search" && start_point == "HEAD" @@ -1610,16 +1633,16 @@ fn parse_checkout_and_switch_force_args() { arg: Some("--force feature/search"), }; assert!(matches!( - GraftPragma::try_from(&json_branch_delete).unwrap(), - GraftPragma::JsonBranchDelete { name, force: true } if name == "feature/search" + GraftCommand::parse(&json_branch_delete).unwrap(), + GraftCommand::JsonBranchDelete { name, force: true } if name == "feature/search" )); let json_branch_rename = Pragma { name: "graft_json_branch_rename", arg: Some("feature/search feature/query"), }; assert!(matches!( - GraftPragma::try_from(&json_branch_rename).unwrap(), - GraftPragma::JsonBranchRename { + GraftCommand::parse(&json_branch_rename).unwrap(), + GraftCommand::JsonBranchRename { old: Some(old), new, force: false, @@ -1630,8 +1653,8 @@ fn parse_checkout_and_switch_force_args() { arg: Some("feature/query origin/main"), }; assert!(matches!( - GraftPragma::try_from(&json_branch_upstream).unwrap(), - GraftPragma::JsonBranchUpstream { + GraftCommand::parse(&json_branch_upstream).unwrap(), + GraftCommand::JsonBranchUpstream { branch: Some(branch), remote, remote_branch, @@ -1642,8 +1665,8 @@ fn parse_checkout_and_switch_force_args() { arg: Some("feature/query"), }; assert!(matches!( - GraftPragma::try_from(&json_branch_unset_upstream).unwrap(), - GraftPragma::JsonBranchUnsetUpstream { branch: Some(branch) } + GraftCommand::parse(&json_branch_unset_upstream).unwrap(), + GraftCommand::JsonBranchUnsetUpstream { branch: Some(branch) } if branch == "feature/query" )); } diff --git a/crates/graft-sqlite/src/repo_service.rs b/crates/graft-sqlite/src/repo_service.rs new file mode 100644 index 00000000..43c42561 --- /dev/null +++ b/crates/graft-sqlite/src/repo_service.rs @@ -0,0 +1,91 @@ +//! Repository command execution without routing through `SQLite` PRAGMAs. +//! +//! This is the control-plane entry point for CLI and embedding use cases. The `SQLite` VFS remains +//! a data-plane component and only exposes VFS-specific diagnostics and controls. + +use std::{path::Path, sync::Arc}; + +use graft::{remote::RemoteConfig, repo::Repository, setup::setup_graft_temporary}; + +use crate::{ + file::vol_file::VolFile, + pragma::GraftCommand, + vfs::{ErrCtx, RepoRuntimeRegistry}, +}; + +/// A parsed, type-checked repository control-plane command. +/// +/// Parsing is kept at the CLI adapter boundary. Once constructed, command execution no longer +/// carries a string command name or `SQLite` PRAGMA value through the service layer. +pub struct RepositoryCommand { + command: GraftCommand, +} + +impl RepositoryCommand { + pub fn parse(name: &str, argument: Option<&str>) -> Result { + let command = GraftCommand::parse_repository(name, argument)?; + Ok(Self { command }) + } +} + +/// Executes one repository command against the repository containing `target`. +/// +/// The command is evaluated directly against a repository-scoped Graft runtime. No `SQLite` +/// connection is opened and no PRAGMA is issued. `target` may be a worktree database path or the +/// repository's `.graft` directory for commands that operate on the whole worktree. +pub fn execute_repository_command( + target: &Path, + command: RepositoryCommand, +) -> Result, ErrCtx> { + let mut service = RepositoryCommandService::open(target)?; + service.execute(command) +} + +struct RepositoryCommandService { + file: VolFile, +} + +impl RepositoryCommandService { + fn open(target: &Path) -> Result { + let base_runtime = setup_graft_temporary(RemoteConfig::Memory, None)?; + let runtimes = Arc::new(RepoRuntimeRegistry::new(base_runtime.clone())); + let repo = discover_target_repository(target); + let runtime = match &repo { + Some(repo) => runtimes.runtime_for(repo)?, + None => base_runtime, + }; + let session_path = repo.as_ref().map_or_else( + || target.to_path_buf(), + |repo| repo.graft_dir().to_path_buf(), + ); + let repository_database = repo + .as_ref() + .filter(|repo| target != repo.graft_dir()) + .map(|_| target.to_path_buf()); + let file = VolFile::new_repository_session( + runtime, + session_path.to_string_lossy().into_owned(), + repository_database, + repo, + runtimes, + )?; + Ok(Self { file }) + } + + fn execute(&mut self, command: RepositoryCommand) -> Result, ErrCtx> { + let runtime = self.file.runtime().clone(); + command.command.eval(&runtime, &mut self.file) + } +} + +fn discover_target_repository(target: &Path) -> Option { + if target + .file_name() + .is_some_and(|name| name == graft::repo::GRAFT_DIR) + { + return target + .parent() + .and_then(|parent| Repository::discover(parent).ok()); + } + Repository::discover_for_file(target).ok() +} diff --git a/crates/graft-sqlite/src/vfs.rs b/crates/graft-sqlite/src/vfs.rs index 3fd2fe87..8cb91ee3 100644 --- a/crates/graft-sqlite/src/vfs.rs +++ b/crates/graft-sqlite/src/vfs.rs @@ -35,11 +35,10 @@ use crate::{ mem_file::MemFile, vol_file::{VolFile, WorkspaceCoordinator}, }, - pragma::GraftPragma, + pragma::VfsPragma, }; const SQLITE_DATABASE_MAGIC: &[u8; 16] = b"SQLite format 3\0"; -const LEGACY_WORKSPACE_DATABASE: &str = "control.sqlite"; #[derive(Debug, Error)] pub enum ErrCtx { @@ -70,6 +69,12 @@ pub enum ErrCtx { #[error("Graft repository error: {0}")] Repo(#[from] RepoErr), + #[error("Graft setup error: {0}")] + Setup(#[from] graft::setup::InitErr), + + #[error("SQLite database error: {0}")] + SqliteDatabase(#[from] rusqlite::Error), + #[error(transparent)] IoErr(#[from] std::io::Error), @@ -119,6 +124,7 @@ pub struct GraftVfs { // VolFile locks keyed by tag locks: Mutex>>>, workspace: Arc, + allow_repository_pragmas_for_tests: bool, } #[derive(Debug)] @@ -128,7 +134,7 @@ pub struct RepoRuntimeRegistry { } impl RepoRuntimeRegistry { - fn new(base: Runtime) -> Self { + pub(crate) fn new(base: Runtime) -> Self { Self { base, runtimes: Default::default() } } @@ -154,6 +160,18 @@ impl GraftVfs { runtime, locks: Default::default(), workspace: Arc::new(WorkspaceCoordinator::default()), + allow_repository_pragmas_for_tests: false, + } + } + + /// Constructs a VFS that retains the old repository PRAGMA transport for the legacy + /// integration suite. Production extension registration must use [`Self::new`]. + #[cfg(feature = "test-repository-pragmas")] + #[doc(hidden)] + pub fn new_with_repository_pragmas_for_tests(runtime: Runtime) -> Self { + Self { + allow_repository_pragmas_for_tests: true, + ..Self::new(runtime) } } @@ -221,7 +239,10 @@ impl Vfs for GraftVfs { } ); - if let Some(repo) = workspace_session_repository(&tag) { + #[cfg(feature = "test-repository-pragmas")] + if self.allow_repository_pragmas_for_tests + && let Some(repo) = workspace_session_repository(&tag) + { let runtime = match &repo { Some(repo) => { let runtime = self.repo_runtimes.runtime_for(repo)?; @@ -325,7 +346,9 @@ impl Vfs for GraftVfs { ) -> Result, PragmaErr> { tracing::trace!("pragma: file={handle:?}, pragma={pragma:?}"); if let FileHandle::VolFile(file) = handle { - match GraftPragma::try_from(&pragma)?.eval(&self.runtime, file) { + match VfsPragma::parse(&pragma, self.allow_repository_pragmas_for_tests)? + .eval(&self.runtime, file) + { Ok(val) => Ok(val), Err(err) => Err(PragmaErr::Fail(err.sqlite_err(), Some(format!("{err}")))), } @@ -474,6 +497,7 @@ pub(crate) fn should_discover_repo(tag: &str) -> bool { path.is_absolute() || tag.contains('/') || tag.contains('\\') || path.extension().is_some() } +#[cfg(feature = "test-repository-pragmas")] fn workspace_session_repository(tag: &str) -> Option> { let path = Path::new(tag); if path.file_name()? != std::ffi::OsStr::new(graft::repo::GRAFT_DIR) { @@ -486,10 +510,13 @@ fn workspace_session_repository(tag: &str) -> Option Result<(), ErrCtx> { + const LEGACY_WORKSPACE_DATABASE: &str = "control.sqlite"; + let path = repo.graft_dir().join(LEGACY_WORKSPACE_DATABASE); runtime.tag_delete(&path.to_string_lossy())?; for suffix in ["", "-journal", "-wal", "-shm"] { diff --git a/crates/graft-test/Cargo.toml b/crates/graft-test/Cargo.toml index f9cfdc01..4bab1ee5 100644 --- a/crates/graft-test/Cargo.toml +++ b/crates/graft-test/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "graft-test" -version = "0.6.1" +version = "0.7.0" edition = "2024" authors = { workspace = true } license = { workspace = true } @@ -13,7 +13,7 @@ workspace = true [dependencies] graft = { path = "../graft", features = ["testutil", "precept"] } -graft-sqlite = { path = "../graft-sqlite" } +graft-sqlite = { path = "../graft-sqlite", features = ["test-repository-pragmas"] } graft-tracing = { path = "../graft-tracing" } anyhow = { workspace = true } diff --git a/crates/graft-test/src/lib.rs b/crates/graft-test/src/lib.rs index 23895805..49cfc57f 100644 --- a/crates/graft-test/src/lib.rs +++ b/crates/graft-test/src/lib.rs @@ -125,7 +125,7 @@ impl GraftTestRuntime { }; register_static( vfs_id.clone(), - GraftVfs::new(self.runtime.clone()), + GraftVfs::new_with_repository_pragmas_for_tests(self.runtime.clone()), RegisterOpts { make_default: false }, ) .expect("failed to register vfs"); diff --git a/crates/graft-test/tests/sqlite.rs b/crates/graft-test/tests/sqlite.rs index bf17145b..eac55cd5 100644 --- a/crates/graft-test/tests/sqlite.rs +++ b/crates/graft-test/tests/sqlite.rs @@ -2592,7 +2592,7 @@ fn test_repo_status_and_diff_do_not_persist_physical_sqlite_comparison_volumes() } #[test] -fn test_repo_gc_prunes_replaced_physical_stages_and_preserves_history() { +fn test_repo_gc_preserves_incremental_physical_stage_history() { graft_test::ensure_test_env(); let temp_dir = tempfile::tempdir().unwrap(); @@ -2656,8 +2656,6 @@ fn test_repo_gc_prunes_replaced_physical_stages_and_preserves_history() { pragma_arg_string(&sqlite, "graft_add", "external.db"), "Added external.db" ); - let replaced_stage_pages = std::fs::metadata(&external_db).unwrap().len() / 4096; - { let external = Connection::open(&external_db).unwrap(); external @@ -2677,10 +2675,12 @@ fn test_repo_gc_prunes_replaced_physical_stages_and_preserves_history() { serde_json::from_str(&pragma_query_string(&sqlite, "graft_json_gc")).unwrap(); assert_eq!(dry_run["operation"], "gc"); assert_eq!(dry_run["dry_run"], true); - assert_eq!(dry_run["candidate_volumes"], 1); - assert_eq!(dry_run["candidate_commits"], 1); - assert_eq!(dry_run["candidate_segments"], 1); - assert_eq!(dry_run["candidate_pages"], replaced_stage_pages); + // The replacement stage extends the same Volume incrementally. Its first changed page remains + // part of the later two-page stage, so there is no abandoned full-snapshot Volume to collect. + assert_eq!(dry_run["candidate_volumes"], 0); + assert_eq!(dry_run["candidate_commits"], 0); + assert_eq!(dry_run["candidate_segments"], 0); + assert_eq!(dry_run["candidate_pages"], 0); assert_eq!( dry_run["candidate_page_bytes"].as_u64().unwrap(), dry_run["candidate_pages"].as_u64().unwrap() * 4096 @@ -6520,9 +6520,8 @@ fn test_repo_add_physical_sqlite_file_rejects_non_graft_page_size() { } let err = pragma_arg_error(&sqlite, "graft_add", "external.db"); - assert!(err.contains("4096-byte pages")); - assert!(err.contains("8192-byte pages")); - assert!(err.contains("VACUUM INTO")); + assert!(err.contains("page size is 8192 bytes"), "{err}"); + assert!(err.contains("Graft requires 4096 bytes"), "{err}"); runtime.shutdown().unwrap(); } diff --git a/crates/graft-tool/Cargo.toml b/crates/graft-tool/Cargo.toml index 358ed6c7..f6c57a03 100644 --- a/crates/graft-tool/Cargo.toml +++ b/crates/graft-tool/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "graft-tool" -version = "0.6.1" +version = "0.7.0" edition = "2024" authors = { workspace = true } license = { workspace = true } @@ -15,7 +15,7 @@ path = "src/main.rs" [dependencies] graft = { path = "../graft" } -graft-sqlite = { path = "../graft-sqlite", features = ["register-static"] } +graft-sqlite = { path = "../graft-sqlite" } anyhow = { workspace = true } clap = { workspace = true, features = ["derive"] } diff --git a/crates/graft-tool/src/main.rs b/crates/graft-tool/src/main.rs index 79f2cb38..c1c8fe63 100644 --- a/crates/graft-tool/src/main.rs +++ b/crates/graft-tool/src/main.rs @@ -2,24 +2,15 @@ use std::{ io::Read, num::NonZeroU64, path::{Path, PathBuf}, - sync::atomic::{AtomicU64, Ordering}, - time::{SystemTime, UNIX_EPOCH}, }; use anyhow::{Context, Result, bail}; use clap::{ArgGroup, Args, Parser, Subcommand, ValueEnum}; use graft::{ core::{LogId, SegmentId, VolumeId}, - remote::RemoteConfig, repo::Repository, - setup::GraftConfig, }; -use rusqlite::{ - Batch, Connection, OpenFlags, fallible_iterator::FallibleIterator, types::ValueRef, -}; - -#[cfg(target_arch = "wasm32")] -use std::sync::OnceLock; +use rusqlite::{Batch, Connection, fallible_iterator::FallibleIterator, types::ValueRef}; #[derive(Subcommand)] enum Command { @@ -48,7 +39,7 @@ enum Command { /// Initialize a .graft repository in the current worktree Init(InitArgs), - /// Execute SQL through the embedded Graft SQLite VFS + /// Execute SQL against a physical `SQLite` worktree database Sql { /// SQL to execute. Reads SQL from stdin when omitted. #[arg( @@ -70,7 +61,9 @@ enum Command { #[arg(short = 'b', long = "branch", conflicts_with = "branch")] branch_option: Option, - /// Remote URI: memory, fs://..., s3://..., s3_compatible://..., graft+https://..., or graft+http://... + #[arg( + help = "Remote URI: https://host/org/repo (or graft+https://host/org/repo), graft+http://host/org/repo for local use, memory, fs://, s3://, or s3_compatible://" + )] remote: String, /// Optional branch to clone. Defaults to remote HEAD, then main. @@ -254,7 +247,7 @@ enum Command { path: Option, }, - /// Export a Graft database snapshot as a physical SQLite file + /// Export a Graft database snapshot as a physical `SQLite` file Export(ExportArgs), /// Reset the current branch to a repository revision @@ -574,7 +567,7 @@ struct ExportArgs { #[arg(short = 's', long)] source: Option, - /// Output path for the physical SQLite database file + /// Output path for the physical `SQLite` database file #[arg(short, long)] output: PathBuf, @@ -593,7 +586,9 @@ enum RemoteCommand { /// Remote name, for example origin name: String, - /// Remote URI: memory, fs://..., s3://..., s3_compatible://..., graft+https://..., or graft+http://... + #[arg( + help = "Remote URI: https://host/org/repo (or graft+https://host/org/repo), graft+http://host/org/repo for local use, memory, fs://, s3://, or s3_compatible://" + )] uri: String, }, @@ -649,7 +644,9 @@ enum RemoteCommand { /// Remote name, for example origin name: String, - /// Remote URI: memory, fs://..., s3://..., s3_compatible://..., graft+https://..., or graft+http://... + #[arg( + help = "Remote URI: https://host/org/repo (or graft+https://host/org/repo), graft+http://host/org/repo for local use, memory, fs://, s3://, or s3_compatible://" + )] uri: String, }, @@ -882,12 +879,17 @@ fn run_command(command: Command, db_override: Option<&Path>) -> Result<()> { } if json { let arg = repo_log_arg(limit, after.as_deref())?; - print_output(run_repo_pragma(db_override, None, "json_log", Some(&arg))?); + print_output(run_repository_command( + db_override, + None, + "json_log", + Some(&arg), + )?); } else { if limit.is_some() || after.is_some() { bail!("log pagination requires --json"); } - print_output(run_repo_pragma(db_override, None, "log", None)?); + print_output(run_repository_command(db_override, None, "log", None)?); } } Command::Init(args) => { @@ -898,27 +900,51 @@ fn run_command(command: Command, db_override: Option<&Path>) -> Result<()> { let branch = branch_option.as_deref().or(branch.as_deref()); let arg = repo_clone_arg(&remote, branch); let db = resolve_clone_workspace_session(db_override)?; - print_output(run_pragma(&db, clone_pragma(json), Some(&arg))?); + print_output(execute_repository_command( + &db, + clone_pragma(json), + Some(&arg), + )?); } Command::Status { json, kind } => { let pragma = if json { "json_status" } else { "status" }; let arg = repo_status_arg(kind); - print_output(run_repo_pragma(db_override, None, pragma, arg.as_deref())?); + print_output(run_repository_command( + db_override, + None, + pragma, + arg.as_deref(), + )?); } Command::Audit(args) => { let pragma = if args.json { "json_audit" } else { "audit" }; let arg = repo_audit_arg(args.repair, args.remote.as_deref()); - print_output(run_repo_pragma(db_override, None, pragma, arg.as_deref())?); + print_output(run_repository_command( + db_override, + None, + pragma, + arg.as_deref(), + )?); } Command::Gc(args) => { let pragma = if args.json { "json_gc" } else { "gc" }; let arg = repo_gc_arg(args.dry_run, args.force); - print_output(run_repo_pragma(db_override, None, pragma, arg.as_deref())?); + print_output(run_repository_command( + db_override, + None, + pragma, + arg.as_deref(), + )?); } Command::LsFiles { json, stage, details, others, kind } => { let pragma = if json { "json_ls_files" } else { "ls_files" }; let arg = repo_ls_files_arg(stage, details, others, kind); - print_output(run_repo_pragma(db_override, None, pragma, arg.as_deref())?); + print_output(run_repository_command( + db_override, + None, + pragma, + arg.as_deref(), + )?); } Command::Payload { command } => match command { PayloadCommand::Fetch(args) => { @@ -928,7 +954,12 @@ fn run_command(command: Command, db_override: Option<&Path>) -> Result<()> { "payload_fetch" }; let arg = repo_payload_fetch_arg(args.remote.as_deref(), args.rev.as_deref()); - print_output(run_repo_pragma(db_override, None, pragma, arg.as_deref())?); + print_output(run_repository_command( + db_override, + None, + pragma, + arg.as_deref(), + )?); } PayloadCommand::Status(args) => { let pragma = if args.json { @@ -937,7 +968,12 @@ fn run_command(command: Command, db_override: Option<&Path>) -> Result<()> { "payload_status" }; let arg = repo_payload_status_arg(args.rev.as_deref()); - print_output(run_repo_pragma(db_override, None, pragma, arg.as_deref())?); + print_output(run_repository_command( + db_override, + None, + pragma, + arg.as_deref(), + )?); } PayloadCommand::Prune(args) => { let pragma = if args.json { @@ -946,34 +982,55 @@ fn run_command(command: Command, db_override: Option<&Path>) -> Result<()> { "payload_prune" }; let arg = repo_payload_prune_arg(args.dry_run, args.force); - print_output(run_repo_pragma(db_override, None, pragma, arg.as_deref())?); + print_output(run_repository_command( + db_override, + None, + pragma, + arg.as_deref(), + )?); } }, Command::Config { command } => match command { ConfigCommand::Get { json, key } => { let pragma = config_get_pragma(json); - print_output(run_repo_pragma(db_override, None, pragma, Some(&key))?); + print_output(run_repository_command( + db_override, + None, + pragma, + Some(&key), + )?); } ConfigCommand::List { json } => { let (pragma, arg) = config_list_pragma(json); - print_output(run_repo_pragma(db_override, None, pragma, arg)?); + print_output(run_repository_command(db_override, None, pragma, arg)?); } ConfigCommand::Set { json, key, value } => { let arg = repo_config_set_arg(&key, &value)?; let pragma = config_set_pragma(json); - print_output(run_repo_pragma(db_override, None, pragma, Some(&arg))?); + print_output(run_repository_command( + db_override, + None, + pragma, + Some(&arg), + )?); } ConfigCommand::Unset { json, key } => { let pragma = config_unset_pragma(json); - print_output(run_repo_pragma(db_override, None, pragma, Some(&key))?); + print_output(run_repository_command( + db_override, + None, + pragma, + Some(&key), + )?); } }, Command::Add(args) => { if db_override.is_none() && !args.all && args.path.is_none() { bail!("add requires a path, --all, or --db "); } - let arg = repo_add_arg(args.all, args.force, args.kind, args.path.as_deref())?; - print_output(run_repo_pragma( + let path = args.path.as_deref().or(db_override); + let arg = repo_add_arg(args.all, args.force, args.kind, path)?; + print_output(run_repository_command( db_override, None, add_pragma(args.json), @@ -984,8 +1041,9 @@ fn run_command(command: Command, db_override: Option<&Path>) -> Result<()> { if db_override.is_none() && args.path.is_none() { bail!("rm requires a path or --db "); } - let arg = repo_rm_arg(args.cached, args.path.as_deref()); - print_output(run_repo_pragma( + let path = args.path.as_deref().or(db_override); + let arg = repo_rm_arg(args.cached, path); + print_output(run_repository_command( db_override, None, rm_pragma(args.json), @@ -993,8 +1051,9 @@ fn run_command(command: Command, db_override: Option<&Path>) -> Result<()> { )?); } Command::Commit { json, message } => { - let output = run_repo_pragma(db_override, None, commit_pragma(json), Some(&message)) - .map_err(clean_commit_error)?; + let output = + run_repository_command(db_override, None, commit_pragma(json), Some(&message)) + .map_err(clean_commit_error)?; print_output(output); } Command::Diff { @@ -1021,15 +1080,25 @@ fn run_command(command: Command, db_override: Option<&Path>) -> Result<()> { to: to.as_deref(), path: path.as_deref(), })?; - print_output(run_repo_pragma(db_override, None, suffix, arg.as_deref())?); + print_output(run_repository_command( + db_override, + None, + suffix, + arg.as_deref(), + )?); } Command::Show { rev, json } => { let suffix = if json { "json_show" } else { "show" }; - print_output(run_repo_pragma(db_override, None, suffix, Some(&rev))?); + print_output(run_repository_command( + db_override, + None, + suffix, + Some(&rev), + )?); } Command::Checkout { json, force, rev, path } => { let arg = repo_checkout_arg(force, &rev, path.as_deref()); - print_output(run_repo_pragma( + print_output(run_repository_command( db_override, None, checkout_pragma(json), @@ -1055,7 +1124,7 @@ fn run_command(command: Command, db_override: Option<&Path>) -> Result<()> { kind, path.as_deref(), )?; - print_output(run_repo_pragma( + print_output(run_repository_command( db_override, None, restore_pragma(json), @@ -1066,22 +1135,18 @@ fn run_command(command: Command, db_override: Option<&Path>) -> Result<()> { if db_override.is_none() && args.path.is_none() { bail!("export requires a database path or --db "); } - let arg = repo_export_arg(args.source.as_deref(), &args.output, args.path.as_deref()); - let command_db = if db_override.is_none() { - args.path.as_deref() - } else { - None - }; - print_output(run_repo_pragma( + let path = args.path.as_deref().or(db_override); + let arg = repo_export_arg(args.source.as_deref(), &args.output, path); + print_output(run_repository_command( db_override, - command_db, + args.path.as_deref(), export_pragma(args.json), Some(&arg), )?); } Command::Reset { json, soft, mixed, hard, rev } => { let arg = repo_reset_arg(&rev, soft, mixed, hard); - print_output(run_repo_pragma( + print_output(run_repository_command( db_override, None, reset_pragma(json), @@ -1113,7 +1178,7 @@ fn run_command(command: Command, db_override: Option<&Path>) -> Result<()> { } else { name }; - print_output(run_repo_pragma( + print_output(run_repository_command( db_override, None, branch_delete_pragma(json), @@ -1139,7 +1204,7 @@ fn run_command(command: Command, db_override: Option<&Path>) -> Result<()> { } } }; - print_output(run_repo_pragma( + print_output(run_repository_command( db_override, None, branch_rename_pragma(json), @@ -1153,7 +1218,7 @@ fn run_command(command: Command, db_override: Option<&Path>) -> Result<()> { Some(name) => format!("{name} {upstream}"), None => upstream, }; - print_output(run_repo_pragma( + print_output(run_repository_command( db_override, None, branch_upstream_pragma(json), @@ -1163,7 +1228,7 @@ fn run_command(command: Command, db_override: Option<&Path>) -> Result<()> { if start_point.is_some() { bail!("branch --unset-upstream accepts at most a branch name"); } - print_output(run_repo_pragma( + print_output(run_repository_command( db_override, None, branch_unset_upstream_pragma(json), @@ -1174,13 +1239,13 @@ fn run_command(command: Command, db_override: Option<&Path>) -> Result<()> { bail!("branch -r/-a accepts no branch name or start point"); } let (pragma, arg) = branch_list_pragma(json, remote, all); - print_output(run_repo_pragma(db_override, None, pragma, arg)?); + print_output(run_repository_command(db_override, None, pragma, arg)?); } else if let Some(name) = name { let arg = match start_point { Some(start_point) => format!("{name} {start_point}"), None => name, }; - print_output(run_repo_pragma( + print_output(run_repository_command( db_override, None, branch_create_pragma(json), @@ -1191,7 +1256,7 @@ fn run_command(command: Command, db_override: Option<&Path>) -> Result<()> { bail!("branch list accepts no start point"); } let (pragma, arg) = branch_list_pragma(json, remote, all); - print_output(run_repo_pragma(db_override, None, pragma, arg)?); + print_output(run_repository_command(db_override, None, pragma, arg)?); } } Command::Tag { @@ -1208,7 +1273,7 @@ fn run_command(command: Command, db_override: Option<&Path>) -> Result<()> { bail!("tag --list does not support patterns yet"); } let (pragma, arg) = tag_list_pragma(json); - print_output(run_repo_pragma(db_override, None, pragma, arg)?); + print_output(run_repository_command(db_override, None, pragma, arg)?); } else if delete { let Some(name) = name else { bail!("tag delete requires a tag name"); @@ -1216,7 +1281,7 @@ fn run_command(command: Command, db_override: Option<&Path>) -> Result<()> { if rev.is_some() { bail!("tag delete accepts only a tag name"); } - print_output(run_repo_pragma( + print_output(run_repository_command( db_override, None, tag_delete_pragma(json), @@ -1240,7 +1305,7 @@ fn run_command(command: Command, db_override: Option<&Path>) -> Result<()> { None => name, } }; - print_output(run_repo_pragma( + print_output(run_repository_command( db_override, None, tag_create_pragma(json), @@ -1251,7 +1316,7 @@ fn run_command(command: Command, db_override: Option<&Path>) -> Result<()> { bail!("tag list accepts no annotation flags"); } let (pragma, arg) = tag_list_pragma(json); - print_output(run_repo_pragma(db_override, None, pragma, arg)?); + print_output(run_repository_command(db_override, None, pragma, arg)?); } } Command::Switch { json, create, force, branch, start_point } => { @@ -1264,7 +1329,12 @@ fn run_command(command: Command, db_override: Option<&Path>) -> Result<()> { switch_branch_pragma(json) }; let arg = repo_switch_arg(force, &branch, start_point.as_deref()); - print_output(run_repo_pragma(db_override, None, pragma, Some(&arg))?); + print_output(run_repository_command( + db_override, + None, + pragma, + Some(&arg), + )?); } Command::Merge { json, @@ -1274,7 +1344,7 @@ fn run_command(command: Command, db_override: Option<&Path>) -> Result<()> { rev, } => { if abort { - print_output(run_repo_pragma( + print_output(run_repository_command( db_override, None, merge_abort_pragma(json), @@ -1287,7 +1357,7 @@ fn run_command(command: Command, db_override: Option<&Path>) -> Result<()> { let Some(message) = message else { bail!("merge --continue requires --message"); }; - print_output(run_repo_pragma( + print_output(run_repository_command( db_override, None, merge_continue_pragma(json), @@ -1297,7 +1367,7 @@ fn run_command(command: Command, db_override: Option<&Path>) -> Result<()> { let Some(rev) = rev else { bail!("merge requires a revision unless --abort is used"); }; - print_output(run_repo_pragma( + print_output(run_repository_command( db_override, None, merge_pragma(json), @@ -1306,7 +1376,7 @@ fn run_command(command: Command, db_override: Option<&Path>) -> Result<()> { } } Command::Conflicts(args) => { - print_output(run_repo_pragma( + print_output(run_repository_command( db_override, args.db.as_deref(), conflicts_pragma(args.json), @@ -1314,8 +1384,9 @@ fn run_command(command: Command, db_override: Option<&Path>) -> Result<()> { )?); } Command::Resolve { json, ours, theirs, manual, row, path } => { - let arg = repo_resolve_arg(ours, theirs, manual, row.as_deref(), path.as_deref())?; - print_output(run_repo_pragma( + let path = path.as_deref().or(db_override); + let arg = repo_resolve_arg(ours, theirs, manual, row.as_deref(), path)?; + print_output(run_repository_command( db_override, None, resolve_pragma(json), @@ -1325,7 +1396,7 @@ fn run_command(command: Command, db_override: Option<&Path>) -> Result<()> { Command::Remote { command } => match command { RemoteCommand::Add { json, name, uri } => { let arg = format!("{name} {uri}"); - print_output(run_repo_pragma( + print_output(run_repository_command( db_override, None, remote_add_pragma(json), @@ -1333,7 +1404,7 @@ fn run_command(command: Command, db_override: Option<&Path>) -> Result<()> { )?); } RemoteCommand::List { json } => { - print_output(run_repo_pragma( + print_output(run_repository_command( db_override, None, remote_list_pragma(json), @@ -1341,7 +1412,7 @@ fn run_command(command: Command, db_override: Option<&Path>) -> Result<()> { )?); } RemoteCommand::Remove { json, name } => { - print_output(run_repo_pragma( + print_output(run_repository_command( db_override, None, remote_remove_pragma(json), @@ -1350,7 +1421,7 @@ fn run_command(command: Command, db_override: Option<&Path>) -> Result<()> { } RemoteCommand::Rename { json, old, new } => { let arg = format!("{old} {new}"); - print_output(run_repo_pragma( + print_output(run_repository_command( db_override, None, remote_rename_pragma(json), @@ -1358,7 +1429,7 @@ fn run_command(command: Command, db_override: Option<&Path>) -> Result<()> { )?); } RemoteCommand::GetUrl { json, name } => { - print_output(run_repo_pragma( + print_output(run_repository_command( db_override, None, remote_get_url_pragma(json), @@ -1367,7 +1438,7 @@ fn run_command(command: Command, db_override: Option<&Path>) -> Result<()> { } RemoteCommand::SetUrl { json, name, uri } => { let arg = format!("{name} {uri}"); - print_output(run_repo_pragma( + print_output(run_repository_command( db_override, None, remote_set_url_pragma(json), @@ -1375,7 +1446,7 @@ fn run_command(command: Command, db_override: Option<&Path>) -> Result<()> { )?); } RemoteCommand::Prune { json, name } => { - print_output(run_repo_pragma( + print_output(run_repository_command( db_override, None, remote_prune_pragma(json), @@ -1384,7 +1455,7 @@ fn run_command(command: Command, db_override: Option<&Path>) -> Result<()> { } }, Command::LsRemote { json, remote } => { - print_output(run_repo_pragma( + print_output(run_repository_command( db_override, None, ls_remote_pragma(json), @@ -1392,7 +1463,7 @@ fn run_command(command: Command, db_override: Option<&Path>) -> Result<()> { )?); } Command::Fetch(args) => { - print_output(run_repo_pragma( + print_output(run_repository_command( db_override, None, fetch_pragma(args.json), @@ -1400,7 +1471,7 @@ fn run_command(command: Command, db_override: Option<&Path>) -> Result<()> { )?); } Command::Pull(args) => { - print_output(run_repo_pragma( + print_output(run_repository_command( db_override, None, pull_pragma(args.json), @@ -1408,7 +1479,7 @@ fn run_command(command: Command, db_override: Option<&Path>) -> Result<()> { )?); } Command::Push(args) => { - print_output(run_repo_pragma( + print_output(run_repository_command( db_override, None, push_pragma(args.json), @@ -1419,7 +1490,7 @@ fn run_command(command: Command, db_override: Option<&Path>) -> Result<()> { Ok(()) } -fn run_repo_pragma( +fn run_repository_command( db_override: Option<&Path>, command_db: Option<&Path>, suffix: &str, @@ -1429,7 +1500,7 @@ fn run_repo_pragma( Some(path) => resolve_cli_db(Some(path))?, None => resolve_repo_workspace_session()?, }; - run_pragma(&db, suffix, arg) + execute_repository_command(&db, suffix, arg) } fn clean_commit_error(err: anyhow::Error) -> anyhow::Error { @@ -2330,28 +2401,19 @@ fn remote_branch_arg(args: &RemoteBranchArgs) -> Result> { }) } -fn run_pragma(db: &Path, suffix: &str, arg: Option<&str>) -> Result> { - let graft = open_graft_connection(db)?; - let pragma = format!("graft_{suffix}"); - - let mut output = None; - if let Some(arg) = arg { - graft.conn.pragma(None, &pragma, arg, |row| { - output = Some(row.get(0)?); - Ok(()) - })?; - } else { - graft.conn.pragma_query(None, &pragma, |row| { - output = Some(row.get(0)?); - Ok(()) - })?; - } - Ok(output) +fn execute_repository_command( + db: &Path, + suffix: &str, + arg: Option<&str>, +) -> Result> { + let command = graft_sqlite::repo_service::RepositoryCommand::parse(suffix, arg)?; + graft_sqlite::repo_service::execute_repository_command(db, command).map_err(anyhow::Error::from) } fn execute_sql(db: &Path, sql: &str) -> Result> { - let graft = open_graft_connection(db)?; - let mut batch = Batch::new(&graft.conn, sql); + let connection = Connection::open(db) + .with_context(|| format!("failed to open physical SQLite database {}", db.display()))?; + let mut batch = Batch::new(&connection, sql); let mut output = String::new(); let mut statement_count = 0; let mut result_count = 0; @@ -2388,11 +2450,8 @@ fn append_query_output(output: &mut String, stmt: &mut rusqlite::Statement<'_>) .into_iter() .map(ToOwned::to_owned) .collect(); - let show_header = !is_graft_pragma_statement(stmt); - if show_header { - output.push_str(&column_names.join("|")); - output.push('\n'); - } + output.push_str(&column_names.join("|")); + output.push('\n'); let mut rows = stmt.query([])?; while let Some(row) = rows.next()? { @@ -2407,14 +2466,6 @@ fn append_query_output(output: &mut String, stmt: &mut rusqlite::Statement<'_>) Ok(()) } -fn is_graft_pragma_statement(stmt: &rusqlite::Statement<'_>) -> bool { - stmt.expanded_sql().is_some_and(|sql| { - sql.trim_start() - .to_ascii_lowercase() - .starts_with("pragma graft_") - }) -} - fn render_sql_value(value: ValueRef<'_>) -> String { match value { ValueRef::Null => "NULL".to_string(), @@ -2435,93 +2486,6 @@ fn hex_encode(bytes: &[u8]) -> String { encoded } -struct GraftConnection { - conn: Connection, - #[cfg(not(target_arch = "wasm32"))] - _vfs: RegisteredVfs, -} - -fn open_graft_connection(db: &Path) -> Result { - let vfs = register_graft_vfs()?; - let db = absolute_db_path(db)?; - let conn = Connection::open_with_flags_and_vfs( - &db, - OpenFlags::SQLITE_OPEN_READ_WRITE | OpenFlags::SQLITE_OPEN_CREATE, - vfs.name.as_str(), - ) - .with_context(|| format!("failed to open {} with VFS {}", db.display(), vfs.name))?; - Ok(GraftConnection { - conn, - #[cfg(not(target_arch = "wasm32"))] - _vfs: vfs, - }) -} - -struct RegisteredVfs { - name: String, - #[cfg(not(target_arch = "wasm32"))] - _data_dir: tempfile::TempDir, - #[cfg(target_arch = "wasm32")] - _data_dir: PathBuf, -} - -#[cfg(target_arch = "wasm32")] -static BROWSER_VFS: OnceLock = OnceLock::new(); - -#[cfg(target_arch = "wasm32")] -fn register_graft_vfs() -> Result<&'static RegisteredVfs> { - if let Some(vfs) = BROWSER_VFS.get() { - return Ok(vfs); - } - let vfs = create_registered_vfs()?; - let _ = BROWSER_VFS.set(vfs); - Ok(BROWSER_VFS - .get() - .expect("browser VFS initialized on this worker")) -} - -#[cfg(not(target_arch = "wasm32"))] -fn register_graft_vfs() -> Result { - create_registered_vfs() -} - -fn create_registered_vfs() -> Result { - let name = format!("graft_cli_{}_{}", std::process::id(), unique_suffix()); - - #[cfg(target_arch = "wasm32")] - let data_dir = { - let path = PathBuf::from("/.graft/tmp/browser-vfs-base"); - std::fs::create_dir_all(&path) - .context("failed to create browser Graft VFS data directory")?; - path - }; - - #[cfg(not(target_arch = "wasm32"))] - let data_dir = tempfile::Builder::new() - .prefix(&name) - .tempdir() - .context("failed to create temporary Graft data directory")?; - - graft_sqlite::register_static( - &name, - false, - GraftConfig { - remote: RemoteConfig::Memory, - #[cfg(target_arch = "wasm32")] - data_dir: data_dir.clone(), - #[cfg(not(target_arch = "wasm32"))] - data_dir: data_dir.path().to_path_buf(), - autosync: None, - }, - )?; - - #[cfg(target_arch = "wasm32")] - return Ok(RegisteredVfs { name, _data_dir: data_dir }); - - #[cfg(not(target_arch = "wasm32"))] - Ok(RegisteredVfs { name, _data_dir: data_dir }) -} - fn absolute_db_path(path: &Path) -> Result { let absolute = if path.is_absolute() { path.to_path_buf() @@ -2542,14 +2506,6 @@ fn absolute_db_path(path: &Path) -> Result { Ok(absolute) } -fn unique_suffix() -> u64 { - static COUNTER: AtomicU64 = AtomicU64::new(0); - let now = SystemTime::now() - .duration_since(UNIX_EPOCH) - .map_or(0, |duration| duration.as_nanos() as u64); - now ^ COUNTER.fetch_add(1, Ordering::Relaxed) -} - #[cfg(test)] mod tests { use super::*; @@ -3654,7 +3610,7 @@ mod tests { } #[test] - fn sql_command_runs_through_graft_vfs() { + fn sql_command_writes_a_physical_worktree_database() { let temp_dir = tempfile::tempdir().unwrap(); let db = temp_dir.path().join("app.db"); graft::repo::Repository::init(temp_dir.path()).unwrap(); @@ -3664,14 +3620,18 @@ mod tests { &[String::from( "CREATE TABLE users(id INTEGER PRIMARY KEY, name TEXT); \ INSERT INTO users(name) VALUES ('Alice'), ('Bob'); \ - SELECT name FROM users ORDER BY id; \ - PRAGMA graft_status;", + SELECT name FROM users ORDER BY id;", )], ) .unwrap() .unwrap(); assert!(output.contains("name\nAlice\nBob\n"), "{output}"); - assert!(output.contains("untracked: app.db"), "{output}"); + assert!(db.is_file()); + + let status = run_repository_command(Some(&db), None, "status", None) + .unwrap() + .unwrap(); + assert!(status.contains("untracked: app.db"), "{status}"); } #[cfg(not(windows))] @@ -3682,11 +3642,9 @@ mod tests { let temp_dir = tempfile::tempdir().unwrap(); let worktree = temp_dir.path().join("space ? #"); let repo = graft::repo::Repository::init(&worktree).unwrap(); - let legacy_workspace_db = repo.graft_dir().join("control.sqlite"); - std::fs::write(&legacy_workspace_db, b"legacy workspace database").unwrap(); std::env::set_current_dir(&worktree).unwrap(); - let output = run_repo_pragma(None, None, "json_status", None) + let output = run_repository_command(None, None, "json_status", None) .unwrap() .expect("status pragma should return JSON"); std::env::set_current_dir(original_dir).unwrap(); @@ -3696,13 +3654,6 @@ mod tests { "{output}" ); assert!(!temp_dir.path().join("space ").exists()); - assert!(!legacy_workspace_db.exists()); - assert!( - std::fs::read_dir(repo.graft_dir()) - .unwrap() - .filter_map(Result::ok) - .all(|entry| entry.path().extension().is_none_or(|ext| ext != "sqlite")) - ); } #[cfg(windows)] @@ -3714,7 +3665,7 @@ mod tests { let repo = graft::repo::Repository::init(temp_dir.path()).unwrap(); std::env::set_current_dir(repo.worktree()).unwrap(); - let output = run_repo_pragma(None, None, "json_status", None) + let output = run_repository_command(None, None, "json_status", None) .unwrap() .expect("status pragma should return JSON"); std::env::set_current_dir(original_dir).unwrap(); @@ -3723,7 +3674,7 @@ mod tests { } #[test] - fn sql_status_reports_untracked_artifacts_in_eidos_worktree() { + fn repository_status_reports_untracked_artifacts_after_physical_sql_writes() { let temp_dir = tempfile::tempdir().unwrap(); let eidos_dir = temp_dir.path().join(".eidos"); let files_dir = eidos_dir.join("files"); @@ -3732,15 +3683,16 @@ mod tests { graft::repo::Repository::init(&eidos_dir).unwrap(); std::fs::write(files_dir.join("icon.png"), b"\x89PNG\r\n\x1a\n").unwrap(); - let output = run_sql( + run_sql( Some(&db), &[String::from( - "CREATE TABLE app_state(id INTEGER PRIMARY KEY); \ - PRAGMA graft_json_status;", + "CREATE TABLE app_state(id INTEGER PRIMARY KEY);", )], ) - .unwrap() .unwrap(); + let output = run_repository_command(Some(&db), None, "json_status", None) + .unwrap() + .unwrap(); assert!(output.contains(r#""path":"files/icon.png""#), "{output}"); assert!(output.contains(r#""kind":"binary_file""#), "{output}"); @@ -3756,16 +3708,19 @@ mod tests { let result = (|| -> Result<()> { run_command(Command::Init(InitArgs { json: false }), None)?; - let output = run_sql( + run_sql( Some(Path::new("sub-app/main.sqlite")), &[String::from( "CREATE TABLE docs(id TEXT PRIMARY KEY, title TEXT); \ - INSERT INTO docs VALUES ('1', 'Hello'); \ - PRAGMA graft_add; \ - PRAGMA graft_json_commit = 'initial docs';", + INSERT INTO docs VALUES ('1', 'Hello');", )], - )? - .unwrap(); + )?; + let db = Path::new("sub-app/main.sqlite"); + let add_arg = repo_add_arg(false, false, None, Some(db))?; + run_repository_command(Some(db), None, "add", add_arg.as_deref())?; + let output = + run_repository_command(Some(db), None, "json_commit", Some("initial docs"))? + .unwrap(); assert!(output.contains("\"materialized\""), "{output}"); let materialized = temp_dir.path().join("sub-app/main.sqlite"); @@ -3784,6 +3739,187 @@ mod tests { result.unwrap(); } + #[test] + fn switch_rejects_an_active_physical_sqlite_writer_before_moving_head() { + let _guard = CWD_LOCK.lock().unwrap(); + let original_dir = std::env::current_dir().unwrap(); + let temp_dir = tempfile::tempdir().unwrap(); + std::env::set_current_dir(temp_dir.path()).unwrap(); + + let result = (|| -> Result<()> { + run_command(Command::Init(InitArgs { json: false }), None)?; + let db = Path::new("app.sqlite"); + run_sql( + Some(db), + &[String::from( + "CREATE TABLE docs(id INTEGER PRIMARY KEY, title TEXT); \ + INSERT INTO docs VALUES (1, 'main');", + )], + )?; + let add_arg = repo_add_arg(false, false, None, Some(db))?; + run_repository_command(Some(db), None, "add", add_arg.as_deref())?; + run_repository_command(Some(db), None, "commit", Some("main state"))?; + run_repository_command(Some(db), None, "switch_create", Some("feature"))?; + + run_sql( + Some(db), + &[String::from( + "UPDATE docs SET title = 'feature' WHERE id = 1;", + )], + )?; + run_repository_command(Some(db), None, "add", add_arg.as_deref())?; + run_repository_command(Some(db), None, "commit", Some("feature state"))?; + run_repository_command(Some(db), None, "switch_branch", Some("main"))?; + + let writer = Connection::open(db)?; + writer.execute_batch( + "BEGIN IMMEDIATE; UPDATE docs SET title = 'in flight' WHERE id = 1;", + )?; + + let error = run_repository_command(Some(db), None, "switch_branch", Some("feature")) + .expect_err("an active physical writer must block branch replacement"); + assert!( + error + .to_string() + .contains("while another transaction is active"), + "{error:#}" + ); + let status = run_repository_command(Some(db), None, "json_status", None)? + .expect("status should return JSON"); + assert!(status.contains(r#""current_branch":"main""#), "{status}"); + let title: String = + writer.query_row("SELECT title FROM docs WHERE id = 1", [], |row| row.get(0))?; + assert_eq!(title, "in flight"); + + writer.execute_batch("ROLLBACK")?; + run_repository_command(Some(db), None, "switch_branch", Some("feature"))?; + let connection = Connection::open(db)?; + let title: String = + connection + .query_row("SELECT title FROM docs WHERE id = 1", [], |row| row.get(0))?; + assert_eq!(title, "feature"); + Ok(()) + })(); + + std::env::set_current_dir(original_dir).unwrap(); + result.unwrap(); + } + + #[test] + fn remove_rejects_an_active_physical_sqlite_writer_before_staging_deletion() { + let _guard = CWD_LOCK.lock().unwrap(); + let original_dir = std::env::current_dir().unwrap(); + let temp_dir = tempfile::tempdir().unwrap(); + std::env::set_current_dir(temp_dir.path()).unwrap(); + + let result = (|| -> Result<()> { + run_command(Command::Init(InitArgs { json: false }), None)?; + let db = Path::new("app.sqlite"); + run_sql( + Some(db), + &[String::from( + "CREATE TABLE docs(id INTEGER PRIMARY KEY, title TEXT); \ + INSERT INTO docs VALUES (1, 'main');", + )], + )?; + let add_arg = repo_add_arg(false, false, None, Some(db))?; + run_repository_command(Some(db), None, "add", add_arg.as_deref())?; + run_repository_command(Some(db), None, "commit", Some("main state"))?; + + let writer = Connection::open(db)?; + writer.execute_batch( + "BEGIN IMMEDIATE; UPDATE docs SET title = 'in flight' WHERE id = 1;", + )?; + + let remove_arg = repo_rm_arg(false, Some(db)); + let error = run_repository_command(Some(db), None, "rm", remove_arg.as_deref()) + .expect_err("an active physical writer must block database removal"); + assert!( + error + .to_string() + .contains("while another transaction is active"), + "{error:#}" + ); + assert!(db.exists()); + let status = run_repository_command(Some(db), None, "json_status", None)? + .expect("status should return JSON"); + assert!(status.contains(r#""has_staged_changes":false"#), "{status}"); + + writer.execute_batch("ROLLBACK")?; + let title: String = + writer.query_row("SELECT title FROM docs WHERE id = 1", [], |row| row.get(0))?; + assert_eq!(title, "main"); + Ok(()) + })(); + + std::env::set_current_dir(original_dir).unwrap(); + result.unwrap(); + } + + #[test] + fn merge_with_db_auto_merges_non_overlapping_rows_into_the_physical_worktree() { + let _guard = CWD_LOCK.lock().unwrap(); + let original_dir = std::env::current_dir().unwrap(); + let temp_dir = tempfile::tempdir().unwrap(); + std::env::set_current_dir(temp_dir.path()).unwrap(); + + let result = (|| -> Result<()> { + run_command(Command::Init(InitArgs { json: false }), None)?; + let db = Path::new("data.sqlite"); + run_sql( + Some(db), + &[String::from( + "CREATE TABLE docs(id INTEGER PRIMARY KEY, body TEXT); \ + INSERT INTO docs VALUES (1, 'one'), (2, 'two');", + )], + )?; + let add_arg = repo_add_arg(false, false, None, Some(db))?; + run_repository_command(Some(db), None, "add", add_arg.as_deref())?; + run_repository_command(Some(db), None, "commit", Some("base"))?; + + run_repository_command(Some(db), None, "switch_create", Some("feature"))?; + run_sql( + Some(db), + &[String::from( + "UPDATE docs SET body = 'theirs' WHERE id = 2;", + )], + )?; + run_repository_command(Some(db), None, "add", add_arg.as_deref())?; + run_repository_command(Some(db), None, "commit", Some("theirs"))?; + + run_repository_command(Some(db), None, "switch_branch", Some("main"))?; + run_sql( + Some(db), + &[String::from("UPDATE docs SET body = 'ours' WHERE id = 1;")], + )?; + run_repository_command(Some(db), None, "add", add_arg.as_deref())?; + run_repository_command(Some(db), None, "commit", Some("ours"))?; + + let output = run_repository_command(Some(db), None, "merge", Some("feature"))? + .expect("merge should return output"); + assert!( + output.contains("Row-level auto-merged data.sqlite"), + "{output}" + ); + let status = run_repository_command(Some(db), None, "json_status", None)? + .expect("status should return JSON"); + assert!(status.contains(r#""has_conflicts":false"#), "{status}"); + + let connection = Connection::open(db)?; + let rows = connection + .prepare("SELECT id, body FROM docs ORDER BY id")? + .query_map([], |row| { + Ok((row.get::<_, i64>(0)?, row.get::<_, String>(1)?)) + })? + .collect::>>()?; + assert_eq!(rows, [(1, "ours".to_string()), (2, "theirs".to_string())]); + Ok(()) + })(); + + std::env::set_current_dir(original_dir).unwrap(); + result.unwrap(); + } + #[test] fn sql_command_requires_explicit_db_and_existing_graft_repo() { let temp_dir = tempfile::tempdir().unwrap(); @@ -3804,6 +3940,26 @@ mod tests { assert!(err.to_string().contains("requires --db "), "{err:#}"); } + #[test] + fn remote_uri_help_describes_canonical_and_compatibility_forms() { + for args in [ + &["graft", "clone", "--help"][..], + &["graft", "remote", "add", "--help"][..], + &["graft", "remote", "set-url", "--help"][..], + ] { + let help = Cli::try_parse_from(args.iter().copied()) + .err() + .expect("help should stop argument parsing") + .to_string(); + assert!(help.contains("https://host/org/repo"), "{help}"); + assert!(help.contains("graft+https://host/org/repo"), "{help}"); + assert!( + help.contains("graft+http://host/org/repo for local use"), + "{help}" + ); + } + } + #[test] fn parses_clone_with_optional_branch() { let cli = Cli::try_parse_from(["graft", "clone", "fs:///srv/graft/app"]).unwrap(); @@ -4404,7 +4560,7 @@ mod tests { } #[test] - fn export_pragma_writes_physical_sqlite_file() { + fn export_command_writes_physical_sqlite_file() { let temp_dir = tempfile::Builder::new() .prefix("graft-export-test") .tempdir_in("/tmp") @@ -4415,16 +4571,17 @@ mod tests { run_sql( Some(&db), - &[format!( + &[String::from( "CREATE TABLE users(id INTEGER PRIMARY KEY, name TEXT); \ - INSERT INTO users(name) VALUES ('Alice'), ('Bob'); \ - PRAGMA graft_add; \ - PRAGMA graft_commit = 'initial users'; \ - PRAGMA graft_export = '--source HEAD --output {}';", - output.display() + INSERT INTO users(name) VALUES ('Alice'), ('Bob');", )], ) .unwrap(); + let add_arg = repo_add_arg(false, false, None, Some(&db)).unwrap(); + run_repository_command(Some(&db), None, "add", add_arg.as_deref()).unwrap(); + run_repository_command(Some(&db), None, "commit", Some("initial users")).unwrap(); + let export_arg = repo_export_arg(Some("HEAD"), &output, Some(&db)); + run_repository_command(Some(&db), None, "export", Some(&export_arg)).unwrap(); let conn = Connection::open(&output).unwrap(); let names: String = conn @@ -4437,6 +4594,52 @@ mod tests { assert_eq!(names, "Alice,Bob"); } + #[test] + fn export_command_snapshots_the_physical_wal_worktree() { + let temp_dir = tempfile::Builder::new() + .prefix("graft-export-wal-test") + .tempdir_in("/tmp") + .unwrap(); + let db = temp_dir.path().join("app.db"); + let output = temp_dir.path().join("snapshot.db"); + graft::repo::Repository::init(temp_dir.path()).unwrap(); + + let connection = Connection::open(&db).unwrap(); + connection + .query_row("PRAGMA journal_mode=WAL", [], |_| Ok(())) + .unwrap(); + connection + .pragma_update(None, "wal_autocheckpoint", 0) + .unwrap(); + connection + .execute_batch( + "CREATE TABLE users(id INTEGER PRIMARY KEY, name TEXT); \ + INSERT INTO users(name) VALUES ('Alice'), ('Bob');", + ) + .unwrap(); + let wal_path = PathBuf::from(format!("{}-wal", db.display())); + assert!(wal_path.exists()); + + let export_arg = repo_export_arg(None, &output, Some(&db)); + run_repository_command(Some(&db), None, "export", Some(&export_arg)).unwrap(); + + assert!( + wal_path.exists(), + "export must not checkpoint the source WAL" + ); + let restored = + Connection::open_with_flags(&output, rusqlite::OpenFlags::SQLITE_OPEN_READ_ONLY) + .unwrap(); + let names: String = restored + .query_row( + "SELECT group_concat(name, ',') FROM users ORDER BY id", + [], + |row| row.get(0), + ) + .unwrap(); + assert_eq!(names, "Alice,Bob"); + } + #[test] fn parses_branch_upstream_flags() { let cli = Cli::try_parse_from([ diff --git a/crates/graft-tracing/Cargo.toml b/crates/graft-tracing/Cargo.toml index 912976d4..59b8f341 100644 --- a/crates/graft-tracing/Cargo.toml +++ b/crates/graft-tracing/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "graft-tracing" -version = "0.6.1" +version = "0.7.0" edition = "2024" authors = { workspace = true } license = { workspace = true } diff --git a/crates/graft/Cargo.toml b/crates/graft/Cargo.toml index 5a2f3f55..13d94209 100644 --- a/crates/graft/Cargo.toml +++ b/crates/graft/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "graft" -version = "0.6.1" +version = "0.7.0" edition = "2024" authors.workspace = true license.workspace = true diff --git a/crates/graft/src/remote.rs b/crates/graft/src/remote.rs index 5c6f8c02..90bc058e 100644 --- a/crates/graft/src/remote.rs +++ b/crates/graft/src/remote.rs @@ -1,4 +1,4 @@ -use std::{env, future, ops::Range, time::Duration}; +use std::{collections::HashSet, env, future, ops::Range, time::Duration}; use crate::core::{LogId, SegmentId, cbe::CBE64, commit::Commit, lsn::LSN}; use bilrost::{Message, OwnedMessage}; @@ -20,6 +20,8 @@ use thiserror::Error; pub mod segment; const REMOTE_CONCURRENCY: usize = 5; +const GRAFT_PROTOCOL_HEADER: &str = "Graft-Protocol"; +const GRAFT_PROTOCOL_VERSION: &str = "1"; enum RemotePath<'a> { /// Commits are stored at `/logs/{logid}/commits/{CBE64 hex LSN}` @@ -60,6 +62,15 @@ pub enum RemoteErr { message: String, }, + #[error( + "HTTP remote protocol mismatch for `{path}`: expected response header `Graft-Protocol: {expected}`, received {received:?}" + )] + HttpProtocolMismatch { + path: String, + expected: &'static str, + received: Option, + }, + #[error("Failed to decode file: {0}")] Decode(#[from] bilrost::DecodeError), @@ -150,6 +161,8 @@ struct HttpRemote { #[derive(Debug, Deserialize)] struct HttpListResponse { paths: Vec, + #[serde(default)] + next_cursor: Option, } impl Remote { @@ -610,16 +623,24 @@ impl HttpRemote { format!("{}/{}/{}", self.url, kind, percent_encode_path(path)) } - fn list_url(&self, prefix: &str) -> String { - format!( + fn list_url(&self, prefix: &str, cursor: Option<&str>) -> String { + let mut url = format!( "{}/list?prefix={}", self.url, percent_encode_component(prefix) - ) + ); + if let Some(cursor) = cursor { + url.push_str("&cursor="); + url.push_str(&percent_encode_component(cursor)); + } + url } fn request(&self, method: reqwest::Method, url: String) -> reqwest::RequestBuilder { - let request = self.client.request(method, url); + let request = self + .client + .request(method, url) + .header(GRAFT_PROTOCOL_HEADER, GRAFT_PROTOCOL_VERSION); if let Some(token) = &self.token { request.bearer_auth(token) } else { @@ -627,7 +648,33 @@ impl HttpRemote { } } + fn check_protocol(response: &reqwest::Response, path: &str) -> Result<()> { + let protocol_headers = response.headers().get_all(GRAFT_PROTOCOL_HEADER); + let mut protocol_versions = protocol_headers.iter(); + let first = protocol_versions.next(); + if first.is_some_and(|value| value.as_bytes() == GRAFT_PROTOCOL_VERSION.as_bytes()) + && protocol_versions.next().is_none() + { + return Ok(()); + } + let received = protocol_headers + .iter() + .fold(None, |received: Option, value| { + let value = String::from_utf8_lossy(value.as_bytes()); + Some(match received { + Some(received) => format!("{received}, {value}"), + None => value.into_owned(), + }) + }); + Err(RemoteErr::HttpProtocolMismatch { + path: path.to_string(), + expected: GRAFT_PROTOCOL_VERSION, + received, + }) + } + async fn check_response(response: reqwest::Response, path: &str) -> Result { + Self::check_protocol(&response, path)?; if response.status().is_success() { return Ok(response); } @@ -650,6 +697,7 @@ impl HttpRemote { .await .map_err(RemoteErr::HttpTransport)?; if response.status().as_u16() == 404 { + Self::check_protocol(&response, path)?; return Ok(false); } Self::check_response(response, path).await?; @@ -663,6 +711,7 @@ impl HttpRemote { .await .map_err(RemoteErr::HttpTransport)?; if response.status().as_u16() == 404 { + Self::check_protocol(&response, path)?; return Ok(None); } let response = Self::check_response(response, path).await?; @@ -694,20 +743,42 @@ impl HttpRemote { } async fn list_raw(&self, prefix: &str) -> Result> { - let response = self - .request(reqwest::Method::GET, self.list_url(prefix)) - .send() - .await - .map_err(RemoteErr::HttpTransport)?; - let response = Self::check_response(response, prefix).await?; - let bytes = response.bytes().await.map_err(RemoteErr::HttpTransport)?; - let list: HttpListResponse = - serde_json::from_slice(&bytes).map_err(|err| RemoteErr::HttpStatus { - status: 502, - path: prefix.to_string(), - message: format!("invalid list response JSON: {err}"), - })?; - Ok(list.paths) + let mut paths = Vec::new(); + let mut cursor = None; + let mut seen_cursors = HashSet::new(); + + loop { + let response = self + .request( + reqwest::Method::GET, + self.list_url(prefix, cursor.as_deref()), + ) + .send() + .await + .map_err(RemoteErr::HttpTransport)?; + let response = Self::check_response(response, prefix).await?; + let bytes = response.bytes().await.map_err(RemoteErr::HttpTransport)?; + let page: HttpListResponse = + serde_json::from_slice(&bytes).map_err(|err| RemoteErr::HttpStatus { + status: 502, + path: prefix.to_string(), + message: format!("invalid list response JSON: {err}"), + })?; + paths.extend(page.paths); + + let Some(next_cursor) = page.next_cursor else { + return Ok(paths); + }; + if next_cursor.is_empty() || !seen_cursors.insert(next_cursor.clone()) { + return Err(RemoteErr::HttpStatus { + status: 502, + path: prefix.to_string(), + message: "list response repeated an empty or previously seen cursor" + .to_string(), + }); + } + cursor = Some(next_cursor); + } } async fn put_raw(&self, path: &str, bytes: Bytes) -> Result<()> { @@ -840,6 +911,176 @@ fn remote_lock_path(path: &str) -> String { #[cfg(test)] mod tests { use super::*; + use tokio::io::{AsyncReadExt, AsyncWriteExt}; + + async fn serve_http_response( + status: &str, + protocol_versions: &[&str], + ) -> (String, tokio::task::JoinHandle) { + let protocol_versions = protocol_versions + .iter() + .map(ToString::to_string) + .collect::>(); + let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap(); + let address = listener.local_addr().unwrap(); + let status = status.to_string(); + let task = tokio::spawn(async move { + let (mut stream, _) = listener.accept().await.unwrap(); + let mut request = Vec::new(); + loop { + let mut buffer = [0_u8; 1024]; + let read = stream.read(&mut buffer).await.unwrap(); + if read == 0 { + break; + } + request.extend_from_slice(&buffer[..read]); + if request.windows(4).any(|window| window == b"\r\n\r\n") { + break; + } + } + let protocol_headers = protocol_versions + .iter() + .map(|version| format!("Graft-Protocol: {version}\r\n")) + .collect::(); + let response = format!( + "HTTP/1.1 {status}\r\n{protocol_headers}Content-Length: 0\r\nConnection: close\r\n\r\n" + ); + stream.write_all(response.as_bytes()).await.unwrap(); + String::from_utf8(request).unwrap() + }); + (format!("http://{address}/org/repo"), task) + } + + #[tokio::test] + async fn http_remote_sends_and_requires_protocol_version() { + let (url, request) = serve_http_response("204 No Content", &["1"]).await; + let remote = HttpRemote::new(url, None).unwrap(); + assert!(remote.has_raw("objects/one").await.unwrap()); + let request = request.await.unwrap(); + assert!( + request + .lines() + .any(|line| line.eq_ignore_ascii_case("Graft-Protocol: 1")) + ); + + let (url, request) = serve_http_response("204 No Content", &[]).await; + let remote = HttpRemote::new(url, None).unwrap(); + assert!(matches!( + remote.has_raw("objects/one").await, + Err(RemoteErr::HttpProtocolMismatch { received: None, .. }) + )); + request.await.unwrap(); + + let (url, request) = serve_http_response("204 No Content", &["2"]).await; + let remote = HttpRemote::new(url, None).unwrap(); + assert!(matches!( + remote.has_raw("objects/one").await, + Err(RemoteErr::HttpProtocolMismatch { + received: Some(version), + .. + }) if version == "2" + )); + request.await.unwrap(); + + let (url, request) = serve_http_response("204 No Content", &["1", "2"]).await; + let remote = HttpRemote::new(url, None).unwrap(); + assert!(matches!( + remote.has_raw("objects/one").await, + Err(RemoteErr::HttpProtocolMismatch { + received: Some(versions), + .. + }) if versions == "1, 2" + )); + request.await.unwrap(); + } + + #[tokio::test] + async fn http_remote_preserves_conditional_status_contracts() { + let (url, request) = serve_http_response("409 Conflict", &["1"]).await; + let remote = HttpRemote::new(url, None).unwrap(); + assert!(matches!( + remote + .compare_and_swap_raw("refs/heads/main", None, Bytes::new()) + .await, + Err(RemoteErr::CompareAndSwap { .. }) + )); + request.await.unwrap(); + + let (url, request) = serve_http_response("409 Conflict", &["1"]).await; + let remote = HttpRemote::new(url, None).unwrap(); + assert!(matches!( + remote.compare_and_delete_raw("refs/heads/main", None).await, + Err(RemoteErr::CompareAndSwap { .. }) + )); + request.await.unwrap(); + + let (url, request) = serve_http_response("412 Precondition Failed", &["1"]).await; + let remote = HttpRemote::new(url, None).unwrap(); + let error = remote + .put_raw_if_not_exists("objects/one", Bytes::new()) + .await + .unwrap_err(); + assert!(matches!(&error, RemoteErr::HttpStatus { status: 412, .. })); + assert!(error.precondition_failed()); + request.await.unwrap(); + + let (url, request) = serve_http_response("404 Not Found", &[]).await; + let remote = HttpRemote::new(url, None).unwrap(); + assert!(matches!( + remote.has_raw("objects/one").await, + Err(RemoteErr::HttpProtocolMismatch { received: None, .. }) + )); + request.await.unwrap(); + + let (url, request) = serve_http_response("404 Not Found", &["1"]).await; + let remote = HttpRemote::new(url, None).unwrap(); + assert!(!remote.has_raw("objects/one").await.unwrap()); + request.await.unwrap(); + } + + #[tokio::test] + async fn http_remote_follows_list_cursors() { + let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap(); + let address = listener.local_addr().unwrap(); + let server = tokio::spawn(async move { + let bodies = [ + r#"{"paths":["objects/one"],"next_cursor":"opaque/+ cursor"}"#, + r#"{"paths":["objects/two"]}"#, + ]; + let mut requests = Vec::new(); + for body in bodies { + let (mut stream, _) = listener.accept().await.unwrap(); + let mut request = Vec::new(); + loop { + let mut buffer = [0_u8; 1024]; + let read = stream.read(&mut buffer).await.unwrap(); + request.extend_from_slice(&buffer[..read]); + if read == 0 || request.windows(4).any(|window| window == b"\r\n\r\n") { + break; + } + } + requests.push(String::from_utf8(request).unwrap()); + let response = format!( + "HTTP/1.1 200 OK\r\nGraft-Protocol: 1\r\nContent-Type: application/json\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{body}", + body.len() + ); + stream.write_all(response.as_bytes()).await.unwrap(); + } + requests + }); + + let remote = HttpRemote::new(format!("http://{address}/org/repo"), None).unwrap(); + assert_eq!( + remote.list_raw("objects/").await.unwrap(), + ["objects/one", "objects/two"] + ); + let requests = server.await.unwrap(); + assert!(requests[0].starts_with("GET /org/repo/list?prefix=objects%2F ")); + assert!( + requests[1] + .starts_with("GET /org/repo/list?prefix=objects%2F&cursor=opaque%2F%2B%20cursor ") + ); + } #[test] fn compare_and_swap_raw_updates_only_when_expected_matches() { diff --git a/docs/src/config/redirects.ts b/docs/src/config/redirects.ts index 75a6eed9..3cea533c 100644 --- a/docs/src/config/redirects.ts +++ b/docs/src/config/redirects.ts @@ -19,6 +19,7 @@ export const DOC_REDIRECTS = { "/docs/internals/graft-objects/": "/docs/internals/object-formats/", "/docs/internals/snapshot-objects-v2/": "/docs/internals/object-formats/", "/docs/internals/row-diff-v2/": "/docs/internals/row-diff-engine/", + "/docs/internals/http-remote-protocol/": "/docs/reference/remote-protocol/", "/zh/docs/get-started/cli/": "/zh/docs/quickstart/cli/", "/zh/docs/get-started/sqlite-extension/": "/zh/docs/quickstart/sqlite-extension/", "/zh/docs/get-started/usage-guide/": "/zh/docs/quickstart/app-state-walkthrough/", @@ -32,4 +33,5 @@ export const DOC_REDIRECTS = { "/zh/docs/internals/graft-objects/": "/zh/docs/internals/object-formats/", "/zh/docs/internals/snapshot-objects-v2/": "/zh/docs/internals/object-formats/", "/zh/docs/internals/row-diff-v2/": "/zh/docs/internals/row-diff-engine/", + "/zh/docs/internals/http-remote-protocol/": "/zh/docs/reference/remote-protocol/", } as const; diff --git a/docs/src/config/sidebar.ts b/docs/src/config/sidebar.ts index e744ec36..d2fbc4ef 100644 --- a/docs/src/config/sidebar.ts +++ b/docs/src/config/sidebar.ts @@ -160,8 +160,8 @@ export const sidebar = [ items: [ { label: "CLI", slug: "docs/reference/cli" }, { - label: "SQLite PRAGMAs", - translations: { "zh-CN": "SQLite PRAGMA" }, + label: "VFS PRAGMAs", + translations: { "zh-CN": "VFS PRAGMA" }, slug: "docs/reference/pragmas", }, { @@ -184,6 +184,11 @@ export const sidebar = [ translations: { "zh-CN": "远端 URI" }, slug: "docs/reference/remote-uris", }, + { + label: "Remote Service Protocol", + translations: { "zh-CN": "Remote Service 协议" }, + slug: "docs/reference/remote-protocol", + }, { label: "Glossary", translations: { "zh-CN": "术语表" }, @@ -221,11 +226,6 @@ export const sidebar = [ translations: { "zh-CN": "行级差异引擎" }, slug: "docs/internals/row-diff-engine", }, - { - label: "HTTP Remote Protocol", - translations: { "zh-CN": "HTTP 远端协议" }, - slug: "docs/internals/http-remote-protocol", - }, ], }, ] satisfies SidebarSection[]; diff --git a/docs/src/content/docs/docs/concepts/app-state-versioning.mdx b/docs/src/content/docs/docs/concepts/app-state-versioning.mdx index 35d2dec5..b8f3d4b0 100644 --- a/docs/src/content/docs/docs/concepts/app-state-versioning.mdx +++ b/docs/src/content/docs/docs/concepts/app-state-versioning.mdx @@ -104,12 +104,7 @@ graft add --all graft commit -m "save checkpoint" ``` -The SQLite extension is useful inside application processes: - -```sql -PRAGMA graft_json_status; -PRAGMA graft_add = '--all'; -PRAGMA graft_commit = 'save checkpoint'; -``` - -Both surfaces operate on the same repository model. +Applications can invoke the CLI with `--json` to consume the same repository +model without scraping terminal text. They continue to open normal SQLite +worktree files through their existing database library. The optional Graft VFS +is a separate live page-storage mode, not a second repository command API. diff --git a/docs/src/content/docs/docs/concepts/branches-and-remotes.mdx b/docs/src/content/docs/docs/concepts/branches-and-remotes.mdx index 8918c687..82e1db2b 100644 --- a/docs/src/content/docs/docs/concepts/branches-and-remotes.mdx +++ b/docs/src/content/docs/docs/concepts/branches-and-remotes.mdx @@ -76,7 +76,7 @@ Graft HTTP protocol: ```text fs:///srv/graft/app s3_compatible://bucket/prefix?endpoint=https://... -graft+https://host/api/graft/v1/repos/org/space +https://host/org/space ``` See [Sync With Remotes](/docs/guides/sync-remotes/) for workflows and diff --git a/docs/src/content/docs/docs/concepts/repository-model.mdx b/docs/src/content/docs/docs/concepts/repository-model.mdx index 31fb3219..1fd589ac 100644 --- a/docs/src/content/docs/docs/concepts/repository-model.mdx +++ b/docs/src/content/docs/docs/concepts/repository-model.mdx @@ -30,9 +30,10 @@ attachments/report.pdf settings.json ``` -When a database is opened through `graft sql --db data.sqlite` or -`vfs=graft`, Graft maps the absolute file path back to the repo-relative path -`data.sqlite`. +`graft sql --db data.sqlite` opens the same physical file as any other SQLite +client. Repository commands map that absolute path back to the repo-relative +path `data.sqlite`. The optional `vfs=graft` mode stores live pages in a Graft +Volume instead, but keeps the same repository path identity. ## Index @@ -48,6 +49,10 @@ graft commit -m "save state" The index is also where unresolved merge stages are stored. +For a physical database, `graft add` asks SQLite for a consistent backup and +compares its 4 KiB pages with the current index or `HEAD`. Unchanged pages stay +referenced by the existing snapshot; only changed pages extend storage history. + ## Commits And Trees A commit points to a tree. The tree maps repository paths to typed objects: diff --git a/docs/src/content/docs/docs/concepts/sqlite-snapshots.mdx b/docs/src/content/docs/docs/concepts/sqlite-snapshots.mdx index 85990c7d..4cba6c9f 100644 --- a/docs/src/content/docs/docs/concepts/sqlite-snapshots.mdx +++ b/docs/src/content/docs/docs/concepts/sqlite-snapshots.mdx @@ -22,23 +22,32 @@ project/ `data.sqlite` is the worktree path. The commit stores a snapshot descriptor backed by Graft storage under `.graft/`. -## Writing Through Graft +## Physical Worktree Mode -When you write through the CLI: +The default mode uses an ordinary SQLite file: ```bash graft sql --db data.sqlite "INSERT INTO notes(body) VALUES ('hello');" ``` -or through SQLite with `vfs=graft`, SQLite commits advance the current Graft -database state. That state becomes repository history after staging and -committing: +The SQL transaction changes `data.sqlite` and possibly its WAL. It does not +write Graft storage directly. Staging is the storage boundary: ```bash graft add data.sqlite graft commit -m "add note" ``` +`graft add` uses SQLite's online backup API to capture one committed database +state. It includes committed WAL frames without checkpointing or modifying the +source database, normalizes the private snapshot into a standalone rollback- +journal file, and compares it page by page with the staged or committed base. +Only changed 4 KiB pages are appended to Graft storage. If nothing changed, the +existing `CommitFileState` and snapshot are reused. + +Rollback-journal databases follow the same path; there simply may be no WAL to +include. Uncommitted transactions are never staged. + ## Materialized Files By default, Graft materializes committed SQLite snapshots back to normal @@ -68,12 +77,26 @@ the current Graft volume. ## External Edits If a standard SQLite tool edits a tracked materialized database directly, -checkpoint WAL data if needed and stage the path: +commit the transaction and stage the path: ```bash -sqlite3 data.sqlite "PRAGMA wal_checkpoint(TRUNCATE);" +sqlite3 data.sqlite "INSERT INTO notes(body) VALUES ('external edit');" graft add data.sqlite ``` -For app integrations, prefer opening the database through the Graft VFS so -Graft can track the current database state directly. +Manual WAL checkpointing is not required. Before checkout, switch, restore, or +another operation that replaces the worktree file, close long-lived database +connections. Graft obtains an exclusive SQLite lock, folds a WAL into the old +main file, removes sidecars, and refuses replacement while another writer is +active. + +## Optional Live VFS Mode + +The SQLite extension can open a path with `vfs=graft` when an application wants +SQLite commits to advance a live Graft Volume directly. This is an advanced +data-plane mode, not the repository control plane. Repository status, staging, +history, merge, and sync still use the CLI. + +Do not edit the same logical database simultaneously through the Graft VFS and +the platform's normal file VFS. Choose one write path and close live +connections before branch operations that replace or rebind database state. diff --git a/docs/src/content/docs/docs/guides/diff-rows-and-files.mdx b/docs/src/content/docs/docs/guides/diff-rows-and-files.mdx index 5ce1f968..f5b6225f 100644 --- a/docs/src/content/docs/docs/guides/diff-rows-and-files.mdx +++ b/docs/src/content/docs/docs/guides/diff-rows-and-files.mdx @@ -105,15 +105,6 @@ modified: app.sqlite new: [Null, Text("write quickstart"), Text("done")] ``` -## Diff From SQLite - -The SQLite extension exposes the same diff: - -```sql -PRAGMA graft_diff = '--rows HEAD~1 HEAD -- data.sqlite'; -PRAGMA graft_json_diff = '--rows HEAD~1 HEAD -- data.sqlite'; -``` - ## How To Present Diffs In An App Use path summaries for navigation and row payloads for detail panes: diff --git a/docs/src/content/docs/docs/guides/export-sqlite.mdx b/docs/src/content/docs/docs/guides/export-sqlite.mdx index 4a247411..8f214faa 100644 --- a/docs/src/content/docs/docs/guides/export-sqlite.mdx +++ b/docs/src/content/docs/docs/guides/export-sqlite.mdx @@ -50,19 +50,19 @@ Graft volume after export. You can inspect exported or materialized files with tools such as `sqlite3`, DB Browser for SQLite, Datasette, or app-specific debuggers. -If you edit a tracked materialized file directly, close the writer, checkpoint -WAL data if needed, and stage the path: +If you edit a tracked materialized file, commit the transaction and stage the +path: ```bash sqlite3 data.sqlite "INSERT INTO notes(body) VALUES ('external edit');" -sqlite3 data.sqlite "PRAGMA wal_checkpoint(TRUNCATE);" graft add data.sqlite graft commit -m "import external edit" ``` -For application code, prefer writing through `graft sql --db` or a SQLite -connection opened with `vfs=graft`. That keeps Graft's current snapshot state -in sync with SQLite writes. +`graft add` includes committed WAL frames without checkpointing the source and +stores only pages that differ from the staged or committed base. Application +code can therefore use its normal SQLite connection. Use `vfs=graft` only when +the application deliberately chooses the optional live Volume mode. ## Disable Materialization diff --git a/docs/src/content/docs/docs/guides/http-remote.mdx b/docs/src/content/docs/docs/guides/http-remote.mdx index 659201df..597ecea6 100644 --- a/docs/src/content/docs/docs/guides/http-remote.mdx +++ b/docs/src/content/docs/docs/guides/http-remote.mdx @@ -1,14 +1,18 @@ --- title: Connect An HTTP Remote -description: Connect Graft v0.6.1 to a server that implements the Graft HTTP remote protocol. +description: Connect Graft to a compatible remote service or deploy the Cloudflare reference service. --- -Graft v0.6.1 includes an HTTP remote client. It can fetch and push repository -objects through a service that implements the Graft HTTP remote protocol. +Graft can fetch and push repository objects through a service that implements +the Graft remote service protocol. -Graft does **not** bundle or deploy that server. You need an existing compatible -service or your own implementation of the protocol described in -[HTTP Remote Protocol](/docs/internals/http-remote-protocol/). +The wire contract is the implementation-independent +[Remote Service Protocol](/docs/reference/remote-protocol/). This repository +also ships the framework-neutral `@eidos.space/graft-remote` protocol engine, +the `@eidos.space/graft-remote-hono` routing adapter, the reusable +`@eidos.space/graft-remote-cloudflare` storage adapter, and a deployable +[Cloudflare reference service](https://github.com/eidos-space/graft) under +`services/graft-remote-cloudflare`; Graft does not deploy it automatically. ## Configure A Remote @@ -17,18 +21,19 @@ Set the bearer token in the process environment, then add the remote: ```bash export GRAFT_REMOTE_TOKEN='grt_...' graft remote add origin \ - 'graft+https://example.com/api/graft/v1/repos/acme/archive' + 'https://example.com/acme/archive' ``` -The `graft+https://` prefix selects the Graft HTTP transport. The underlying -request URL uses normal HTTPS. +The URL follows the same visible shape as a Git HTTPS remote. The explicit +`graft+https://example.com/acme/archive` alias selects the same transport and +remains supported for compatibility. Use `token_env` when the service token lives in a different variable: ```bash export GRAFT_ARCHIVE_TOKEN='grt_...' graft remote add origin \ - 'graft+https://example.com/api/graft/v1/repos/acme/archive?token_env=GRAFT_ARCHIVE_TOKEN' + 'https://example.com/acme/archive?token_env=GRAFT_ARCHIVE_TOKEN' ``` Do not put the token itself in the URI or `.graft/config.toml`. @@ -47,7 +52,7 @@ On another worktree: ```bash graft clone \ - 'graft+https://example.com/api/graft/v1/repos/acme/archive' \ + 'https://example.com/acme/archive' \ main ``` @@ -60,11 +65,25 @@ Use `graft+http://` only for a local server you control: ```bash export GRAFT_REMOTE_TOKEN='dev-token' graft remote add origin \ - 'graft+http://127.0.0.1:8787/api/graft/v1/repos/acme/archive' + 'graft+http://127.0.0.1:8787/acme/archive' ``` For production, use HTTPS and let the service enforce repository authorization, object integrity, request limits, and durable storage. +## Deploy The Cloudflare Reference Service + +The verification service composes all three packages. The Cloudflare package +provides authentication helpers, one SQLite Durable Object per repository for +atomic refs, and R2 storage for immutable bytes. The service README contains +bucket creation, secret configuration, local development, testing, and +deployment commands. + +For Hono on another platform, install `@eidos.space/graft-remote-hono`, mount +`createGraftRemote()`, and implement its storage, authentication, and +authorization interfaces. Other frameworks can build a thin routing adapter on +`@eidos.space/graft-remote`; the core package has no Hono or Cloudflare +dependency. + See [Remote URIs](/docs/reference/remote-uris/) for URI syntax and [Sync With Remotes](/docs/guides/sync-remotes/) for branch workflows. diff --git a/docs/src/content/docs/docs/guides/json-ui.mdx b/docs/src/content/docs/docs/guides/json-ui.mdx index 4fad664f..bed70bc9 100644 --- a/docs/src/content/docs/docs/guides/json-ui.mdx +++ b/docs/src/content/docs/docs/guides/json-ui.mdx @@ -3,9 +3,9 @@ title: App UI From JSON description: Use Graft JSON output to build status panels, review screens, merge UIs, and payload views. --- -Graft's CLI and SQLite extension expose JSON output so applications do not have -to scrape terminal text. Use JSON for status badges, change review screens, -merge conflict flows, sync progress, and payload cache views. +Graft CLI commands expose JSON output so applications do not have to scrape +terminal text. Use JSON for status badges, change review screens, merge +conflict flows, sync progress, and payload cache views. ## Status Panel @@ -13,10 +13,6 @@ merge conflict flows, sync progress, and payload cache views. graft status --json ``` -```sql -PRAGMA graft_json_status; -``` - Useful fields: | Field | Meaning | @@ -51,10 +47,6 @@ graft diff --json graft diff --json --rows HEAD~1 HEAD data.sqlite ``` -```sql -PRAGMA graft_json_diff = '--rows HEAD~1 HEAD -- data.sqlite'; -``` - Use the top-level `paths` array as the navigation model. Use SQLite row payloads inside database entries for detail panes. @@ -65,11 +57,6 @@ graft conflicts --json graft resolve --json --theirs --row docs 42 data.sqlite ``` -```sql -PRAGMA graft_json_conflicts; -PRAGMA graft_json_resolve_conflict = '--theirs --row docs 42 data.sqlite'; -``` - Render different controls by path type: | Path type | Suggested UI | diff --git a/docs/src/content/docs/docs/guides/sync-remotes.mdx b/docs/src/content/docs/docs/guides/sync-remotes.mdx index 648ebf89..1ee0a6a8 100644 --- a/docs/src/content/docs/docs/guides/sync-remotes.mdx +++ b/docs/src/content/docs/docs/guides/sync-remotes.mdx @@ -13,8 +13,8 @@ memory fs:///absolute/path s3://bucket/prefix s3_compatible://bucket/prefix?endpoint=https://... -graft+https://host/api/graft/v1/repos/org/space -graft+http://127.0.0.1:8787/api/graft/v1/repos/org/space +https://host/org/space +graft+http://127.0.0.1:8787/org/space ``` ## Add A Remote @@ -40,14 +40,14 @@ Use a Graft HTTP remote: ```bash export GRAFT_REMOTE_TOKEN='grt_...' -graft remote add origin 'graft+https://example.com/api/graft/v1/repos/acme/archive' +graft remote add origin 'https://example.com/acme/archive' ``` For a non-default token variable: ```bash export GRAFT_ARCHIVE_TOKEN='grt_...' -graft remote add origin 'graft+https://example.com/api/graft/v1/repos/acme/archive?token_env=GRAFT_ARCHIVE_TOKEN' +graft remote add origin 'https://example.com/acme/archive?token_env=GRAFT_ARCHIVE_TOKEN' ``` ## Push @@ -125,5 +125,4 @@ graft ls-remote origin ``` See [Remote URIs](/docs/reference/remote-uris/) for exact URI parsing rules and -[Connect An HTTP Remote](/docs/guides/http-remote/) for the v0.6.1 HTTP client -workflow. +[Connect An HTTP Remote](/docs/guides/http-remote/) for the HTTP client workflow. diff --git a/docs/src/content/docs/docs/guides/track-databases-and-files.mdx b/docs/src/content/docs/docs/guides/track-databases-and-files.mdx index ccfed2fc..fcf911ad 100644 --- a/docs/src/content/docs/docs/guides/track-databases-and-files.mdx +++ b/docs/src/content/docs/docs/guides/track-databases-and-files.mdx @@ -114,13 +114,17 @@ graft commit -m "stop tracking report" ## External SQLite Edits -If a standard SQLite tool edits a materialized database file directly, close the -writer, checkpoint WAL if needed, then stage the path: +Normal SQLite tools are the default way to edit a physical worktree database. +Commit the transaction, then stage the path: ```bash -sqlite3 data.sqlite "PRAGMA wal_checkpoint(TRUNCATE);" +sqlite3 data.sqlite "INSERT INTO notes(body) VALUES ('hello');" graft add data.sqlite ``` -Writing through `graft sql --db data.sqlite` or `vfs=graft` keeps Graft aware -of the database snapshot immediately. +`graft add` captures committed WAL frames through SQLite's online backup API; +manual checkpointing is unnecessary. It compares the consistent snapshot with +the staged or committed base and writes only changed 4 KiB pages. + +The optional `vfs=graft` mode advances live Graft Volume pages directly. Do not +mix normal-file writes and Graft-VFS writes for the same logical database. diff --git a/docs/src/content/docs/docs/internals/architecture.mdx b/docs/src/content/docs/docs/internals/architecture.mdx index 29e8b837..0638c427 100644 --- a/docs/src/content/docs/docs/internals/architecture.mdx +++ b/docs/src/content/docs/docs/internals/architecture.mdx @@ -4,19 +4,21 @@ description: How the Graft core, SQLite integration, CLI, repository layer, and --- This page is for contributors and advanced debugging. Product integrations -should normally use the CLI, SQLite PRAGMAs, JSON output, and config reference. +should normally use the CLI, JSON output, and config reference. The SQLite +extension is an optional data-plane integration. ## Layers ```text Application - SQLite connection using vfs=graft - or graft CLI + ordinary SQLite connection -> physical worktree database + optional SQLite connection using vfs=graft + graft CLI / JSON graft-sqlite - VFS - PRAGMA parser - repository command adapters + repository command service (control plane) + physical SQLite snapshot/import boundary + optional VFS + VFS diagnostic PRAGMAs (data plane) graft repository model @@ -33,7 +35,7 @@ Remote storage | Crate | Role | | --- | --- | | `graft` | Core storage, repository objects, refs, index, merge, sync, remotes. | -| `graft-sqlite` | SQLite VFS and repository PRAGMA implementation. | +| `graft-sqlite` | Repository command service, physical SQLite import boundary, and optional SQLite VFS. | | `graft-ext` | Dynamic/static SQLite extension wrapper. | | `graft-tool` | CLI that maps arguments to repository operations. | | `graft-test` | Integration test harnesses and workloads. | @@ -41,29 +43,33 @@ Remote storage ## CLI Flow -Most CLI repository commands are thin wrappers around repository pragmas: +CLI repository commands call the typed repository service directly: ```text -graft status -> PRAGMA graft_status -graft add --all -> PRAGMA graft_add = '--all' -graft commit -m "msg" -> PRAGMA graft_commit = 'msg' -graft diff --rows ... -> PRAGMA graft_diff = '--rows ...' +CLI arguments + -> RepositoryCommand parser + -> repository-scoped runtime/session + -> repository operation ``` -`graft sql --db ` opens the database through the embedded Graft VFS and -executes SQL directly. +No SQLite connection or PRAGMA is used for that control flow. `graft sql --db +` separately opens the physical database with ordinary SQLite. ## Commit Flow ```text -SQLite writes through Graft VFS - -> current database state advances - -> graft add records snapshot/artifact entries in the index +SQLite commits to a physical database (main file and possibly WAL) + -> graft add takes a consistent private SQLite backup + -> unchanged pages are reused and changed pages extend Graft storage + -> the index records the resulting snapshot descriptor -> graft commit writes a tree and commit object -> branch ref moves to the new commit - -> SQLite snapshots may be materialized back to worktree files + -> SQLite snapshots are materialized back to physical worktree files ``` +The optional Graft VFS skips the physical import step because SQLite writes +Volume pages directly. It does not expose repository commands. + For file artifacts, staging records inline blobs or external payload pointers. ## Checkout And Switch Flow @@ -71,11 +77,13 @@ For file artifacts, staging records inline blobs or external payload pointers. ```text resolve target commit -> compare target tree to current worktree/index state + -> acquire SQLite replacement locks and normalize/remove WAL sidecars -> materialize tracked SQLite snapshots and files -> update HEAD or branch ``` Conflicting local work blocks checkout unless the command is explicitly forced. +An active SQLite writer also blocks physical database replacement. ## Merge Flow diff --git a/docs/src/content/docs/docs/internals/http-remote-protocol.mdx b/docs/src/content/docs/docs/internals/http-remote-protocol.mdx deleted file mode 100644 index 7ea3926f..00000000 --- a/docs/src/content/docs/docs/internals/http-remote-protocol.mdx +++ /dev/null @@ -1,77 +0,0 @@ ---- -title: HTTP Remote Protocol -description: Internal protocol used by graft+http and graft+https remotes. ---- - -The `graft+http` and `graft+https` remote backend uses a simple object protocol. -The Cloudflare Worker service implements this protocol under: - -```text -/api/graft/v1/repos// -``` - -Clients configure it as: - -```text -graft+https://host/api/graft/v1/repos/acme/archive -``` - -## Authentication - -The client reads a bearer token from the environment. By default: - -```bash -export GRAFT_REMOTE_TOKEN='grt_...' -``` - -The URI can specify another environment variable: - -```text -graft+https://host/api/graft/v1/repos/acme/archive?token_env=GRAFT_ARCHIVE_TOKEN -``` - -## Operations - -The protocol works with raw object paths, compare-and-swap for refs, and object -listing. - -Conceptual endpoint shape: - -```text -GET /raw/ -HEAD /raw/ -PUT /raw/ -DELETE /raw/ -PUT /raw-if-not-exists/ -POST /cas/ -POST /cad/ -GET /list?prefix= -``` - -Ref paths are stored transactionally by the service. Object paths are stored in -the backing object store. - -## Status Codes - -| Status | Meaning | -| --- | --- | -| `204` | Object exists for `HEAD`. | -| `404` | Object or ref is missing. | -| `412` | Compare-and-swap or put-if-absent precondition failed. | - -The Rust client maps these responses into remote errors such as not found, -precondition failed, compare-and-swap failure, or lock busy. - -## Range Reads - -HTTP remotes can serve byte ranges for raw objects using the standard `Range` -header. This allows clients to fetch portions of larger objects when needed. - -## Server Implementation Requirements - -Graft v0.6.1 ships the client, not a bundled HTTP server. A compatible service -must provide durable immutable object storage, transactional compare-and-swap -for refs, bearer-token authorization when the repository is private, and the -status-code behavior above. - -See [Connect An HTTP Remote](/docs/guides/http-remote/) for the client workflow. diff --git a/docs/src/content/docs/docs/internals/row-diff-engine.mdx b/docs/src/content/docs/docs/internals/row-diff-engine.mdx index 5bde80fc..dfad0765 100644 --- a/docs/src/content/docs/docs/internals/row-diff-engine.mdx +++ b/docs/src/content/docs/docs/internals/row-diff-engine.mdx @@ -16,13 +16,6 @@ graft diff --rows HEAD~1 HEAD data.sqlite graft diff --json --rows HEAD~1 HEAD data.sqlite ``` -SQLite: - -```sql -PRAGMA graft_diff = '--rows HEAD~1 HEAD -- data.sqlite'; -PRAGMA graft_json_diff = '--rows HEAD~1 HEAD -- data.sqlite'; -``` - ## Debug Entry Points Low-level storage diagnostics can compare raw storage LSNs: @@ -67,7 +60,7 @@ During merge, the same analysis can produce: - blocked reasons - apply policy and validation details -This output feeds `graft conflicts --json` and `PRAGMA graft_json_conflicts`. +This output feeds `graft conflicts --json`. ## Resolution Layer diff --git a/docs/src/content/docs/docs/overview/installation.mdx b/docs/src/content/docs/docs/overview/installation.mdx index 872b0470..02b4efe8 100644 --- a/docs/src/content/docs/docs/overview/installation.mdx +++ b/docs/src/content/docs/docs/overview/installation.mdx @@ -14,19 +14,19 @@ Install the version documented by this site: ```bash curl -fsSL https://raw.githubusercontent.com/eidos-space/graft/main/install.sh \ - | GRAFT_VERSION=0.6.1 sh + | GRAFT_VERSION=0.7.0 sh ``` Then check the binary: ```bash graft --version -# graft 0.6.1 +# graft 0.7.0 ``` Prebuilt archives are also available from the @@ -70,8 +70,9 @@ The exact library filename depends on the platform. It is written under .load ./libgraft_ext ``` -Use the extension when your app process already owns SQLite connections and -wants to call `PRAGMA graft_*` directly. +Use the extension when your app deliberately wants the Graft VFS as a live +page-storage data plane. Repository commands are not exposed as SQLite +PRAGMAs; use the CLI and its JSON output for the control plane. ## Build The Docs @@ -85,6 +86,6 @@ pnpm build ## Next -Run the [CLI quickstart](/docs/quickstart/cli/) first. Then use the -[SQLite extension quickstart](/docs/quickstart/sqlite-extension/) when you are -ready to embed Graft in an application. +Run the [CLI quickstart](/docs/quickstart/cli/) first. Use the +[SQLite extension quickstart](/docs/quickstart/sqlite-extension/) only when the +application deliberately opts into the Graft VFS. diff --git a/docs/src/content/docs/docs/overview/status.mdx b/docs/src/content/docs/docs/overview/status.mdx index 8fcab7d9..84e83f70 100644 --- a/docs/src/content/docs/docs/overview/status.mdx +++ b/docs/src/content/docs/docs/overview/status.mdx @@ -4,12 +4,12 @@ description: Current Graft capabilities, stability boundaries, and what to expec --- Graft is an experimental project. This documentation targets the -[v0.6.1 release](https://github.com/eidos-space/graft/releases/latest) and +[v0.7.0 release](https://github.com/eidos-space/graft/releases/latest) and focuses on app-state versioning for SQLite-backed apps: SQLite database snapshots, app-owned files, row-aware diffs, merge support, and remote sync. -Features that exist only on the development branch are not part of the v0.6.1 +Features that exist only on the development branch are not part of the v0.7.0 documentation unless a page labels them as unreleased. ## Available Today @@ -19,7 +19,8 @@ documentation unless a page labels them as unreleased. `diff`, `checkout`, `restore`, `export`, `reset`, `branch`, `switch`, `tag`, `merge`, `conflicts`, `resolve`, `remote`, `ls-remote`, `fetch`, `pull`, and `push`. -- A SQLite extension that exposes repository operations through `PRAGMA graft_*`. +- A SQLite extension that provides the optional Graft VFS plus version and + low-level `graft_debug_*` diagnostics. - Repository-local state under `.graft/`. - Multiple tracked SQLite database paths in one worktree. - Text and binary file artifacts in the same commit tree as SQLite snapshots. @@ -32,8 +33,8 @@ documentation unless a page labels them as unreleased. ## Stability Boundaries -Treat repository-mode CLI commands, SQLite repository pragmas, config keys, and -JSON output as the intended external surfaces. They may still change while the +Treat repository-mode CLI commands, config keys, and JSON output as the intended +external surfaces. They may still change while the project is experimental, but they are the surfaces this documentation is organized around. @@ -50,20 +51,25 @@ debugging, not as product-level API guarantees. ## Compatibility Notes -Graft works alongside ordinary SQLite tooling by materializing committed -snapshots back to SQLite database files. When an external tool edits a tracked -database file directly, close the writer, checkpoint WAL data if needed, then -stage the physical path with `graft add`. +Graft works alongside ordinary SQLite tooling by using physical worktree files +and materializing committed snapshots back to those paths. After an external +tool commits a transaction, stage the physical path with `graft add`. The +staging snapshot includes committed WAL frames, so a manual checkpoint is not +required. ```bash -sqlite3 data.sqlite "PRAGMA wal_checkpoint(TRUNCATE);" +sqlite3 data.sqlite "INSERT INTO notes(body) VALUES ('hello');" graft add data.sqlite ``` +Close database connections before branch, checkout, restore, or reset operations +that replace the file. Graft refuses to replace a database under an active +writer. + ## Release Artifacts Prebuilt CLI and SQLite extension archives are published with the -[v0.6.1 GitHub release](https://github.com/eidos-space/graft/releases/latest). +[v0.7.0 GitHub release](https://github.com/eidos-space/graft/releases/latest). The repository is an experimental fork of [orbitinghail/graft](https://github.com/orbitinghail/graft). The upstream diff --git a/docs/src/content/docs/docs/overview/what-is-graft.mdx b/docs/src/content/docs/docs/overview/what-is-graft.mdx index 11deff62..9a6783d8 100644 --- a/docs/src/content/docs/docs/overview/what-is-graft.mdx +++ b/docs/src/content/docs/docs/overview/what-is-graft.mdx @@ -90,12 +90,10 @@ graft add --all graft commit -m "seed notes" ``` -Applications usually integrate through the SQLite extension: +Applications can invoke the same CLI with `--json` and consume structured +status, diff, conflict, and sync results. SQLite itself remains an ordinary +physical worktree file, so existing database libraries need no custom VFS. -```sql -PRAGMA graft_json_status; -PRAGMA graft_add = '--all'; -PRAGMA graft_commit = 'save checkpoint'; -``` - -The CLI and extension share the same repository semantics. +The SQLite extension is optional. It provides the Graft VFS for applications +that deliberately want live page storage, plus version and low-level debug +PRAGMAs. Repository operations remain in the CLI control plane. diff --git a/docs/src/content/docs/docs/quickstart/cli.mdx b/docs/src/content/docs/docs/quickstart/cli.mdx index 6be91601..b6b1c75f 100644 --- a/docs/src/content/docs/docs/quickstart/cli.mdx +++ b/docs/src/content/docs/docs/quickstart/cli.mdx @@ -5,13 +5,14 @@ description: Create a Graft repository, commit SQLite data and files, inspect ro import { Aside, Steps } from "@astrojs/starlight/components"; -The CLI is the easiest way to try Graft. It opens SQLite databases through the -embedded Graft VFS, so you can create database state, stage it, commit it, and -diff it without loading the SQLite extension manually. +The CLI is the easiest way to try Graft. `graft sql` opens an ordinary physical +SQLite worktree file, while repository commands use Graft's control-plane +service directly. No SQLite extension or repository PRAGMA transport is +involved. @@ -34,7 +35,7 @@ diff it without loading the SQLite extension manually. This creates `.graft/`. It does not create a default SQLite database. -3. Write SQLite data through Graft. +3. Write SQLite data. ```bash graft sql --db data.sqlite "CREATE TABLE notes(id INTEGER PRIMARY KEY, body TEXT);" @@ -49,6 +50,9 @@ diff it without loading the SQLite extension manually. `data.sqlite` appears as an unstaged SQLite database snapshot. + At this point it is still a normal SQLite file. Graft reads it only when a + repository command needs to inspect or stage the path. + 5. Commit the first app-state version. ```bash diff --git a/docs/src/content/docs/docs/quickstart/playground.mdx b/docs/src/content/docs/docs/quickstart/playground.mdx index d2ae74b1..40c5ff96 100644 --- a/docs/src/content/docs/docs/quickstart/playground.mdx +++ b/docs/src/content/docs/docs/quickstart/playground.mdx @@ -1,11 +1,11 @@ --- title: Playground -description: Try the real Graft v0.6.1 CLI in a zero-install, browser-only worktree. +description: Try the real Graft v0.7.0 CLI in a zero-install, browser-only worktree. --- import { Aside, Steps } from "@astrojs/starlight/components"; -The [Graft Playground](/playground/) runs the real Graft v0.6.1 CLI compiled to +The [Graft Playground](/playground/) runs the real Graft v0.7.0 CLI compiled to WebAssembly. It stores the worktree, SQLite databases, and `.graft/` repository in the browser's Origin Private File System (OPFS), so you can learn the model without installing Graft or changing local files. @@ -19,7 +19,7 @@ without installing Graft or changing local files. -1. [Open the Playground](/playground/) and wait for the **GRAFT v0.6.1 · WASM** +1. [Open the Playground](/playground/) and wait for the **GRAFT v0.7.0 · WASM** runtime indicator. 2. Open **Guide**. Start with **Basics** to initialize a repository, create `data.sqlite`, add files, stage changes, and commit them. @@ -39,7 +39,7 @@ repository state. | Area | What it demonstrates | | --- | --- | | Files | Text, image, and SQLite paths in one app-state worktree. | -| Terminal | The v0.6.1 CLI plus a small OPFS shell. | +| Terminal | The v0.7.0 CLI plus a small OPFS shell. | | Version | Status, staging, commits, branches, reset, and history. | | Diffs | Text changes, image versions, and SQLite row diffs. | | Conflicts | Three-way merge state and row-aware resolution. | @@ -54,5 +54,6 @@ repository state. ## Continue On Your Machine After the guided tour, run the [CLI Quickstart](/docs/quickstart/cli/) to repeat -the same repository flow in a local directory. Application integrators can -continue with the [SQLite Extension Quickstart](/docs/quickstart/sqlite-extension/). +the same repository flow in a local directory. If your application deliberately +needs the optional Graft VFS, see the +[SQLite Extension Quickstart](/docs/quickstart/sqlite-extension/). diff --git a/docs/src/content/docs/docs/quickstart/sqlite-extension.mdx b/docs/src/content/docs/docs/quickstart/sqlite-extension.mdx index e44aa0b7..571638a3 100644 --- a/docs/src/content/docs/docs/quickstart/sqlite-extension.mdx +++ b/docs/src/content/docs/docs/quickstart/sqlite-extension.mdx @@ -1,116 +1,94 @@ --- title: SQLite Extension Quickstart -description: Use Graft from SQLite with the Graft VFS and repository PRAGMA commands. +description: Use the optional Graft VFS as a live SQLite page-storage data plane. --- import { Aside, Steps } from "@astrojs/starlight/components"; -The SQLite extension is the main integration surface for applications. It -registers a Graft VFS and exposes repository operations through -`PRAGMA graft_*` commands. +Most applications should use ordinary SQLite worktree files and the Graft CLI. +The SQLite extension is for applications that deliberately want SQLite pages to +live in a Graft Volume while the database is open. -Use it when your app already opens SQLite connections from Electron, Node.js, -Python, Ruby, Swift, Rust, or another runtime with SQLite support. +The extension registers `vfs=graft` and exposes version and low-level +`graft_debug_*` PRAGMAs. Repository commands such as status, add, commit, +branch, merge, and sync are not SQLite PRAGMAs; run them through the CLI. -