├── .github └── workflows │ ├── claude.yml │ └── main.yml ├── .gitignore ├── CLAUDE.md ├── LICENSE ├── README.md ├── build.zig ├── build.zig.zon ├── docs ├── development.md ├── schema.md └── style.md └── src ├── cli.zig ├── cmd.zig ├── config.zig ├── e2e.sh ├── git.zig ├── help.zig ├── main.zig ├── protocol.zig ├── remote.zig ├── sqlite.zig ├── tests.zig └── transport.zig /.github/workflows/claude.yml: -------------------------------------------------------------------------------- 1 | name: Claude Code 2 | 3 | on: 4 | issue_comment: 5 | types: [created] 6 | pull_request_review_comment: 7 | types: [created] 8 | issues: 9 | types: [opened, assigned] 10 | pull_request_review: 11 | types: [submitted] 12 | 13 | jobs: 14 | claude: 15 | if: | 16 | (github.event_name == 'issue_comment' && contains(github.event.comment.body, '@claude')) || 17 | (github.event_name == 'pull_request_review_comment' && contains(github.event.comment.body, '@claude')) || 18 | (github.event_name == 'pull_request_review' && contains(github.event.review.body, '@claude')) || 19 | (github.event_name == 'issues' && (contains(github.event.issue.body, '@claude') || contains(github.event.issue.title, '@claude'))) 20 | runs-on: ubuntu-latest 21 | permissions: 22 | contents: read 23 | pull-requests: read 24 | issues: read 25 | id-token: write 26 | steps: 27 | - name: Checkout repository 28 | uses: actions/checkout@v4 29 | with: 30 | fetch-depth: 1 31 | 32 | - name: Run Claude Code 33 | id: claude 34 | uses: anthropics/claude-code-action@beta 35 | with: 36 | anthropic_api_key: ${{ secrets.ANTHROPIC_API_KEY }} 37 | 38 | -------------------------------------------------------------------------------- /.github/workflows/main.yml: -------------------------------------------------------------------------------- 1 | on: 2 | push: 3 | branches: [main] 4 | tags: 5 | - "v*" 6 | pull_request: 7 | branches: [main] 8 | workflow_dispatch: 9 | 10 | jobs: 11 | build: 12 | runs-on: ${{ matrix.os }} 13 | permissions: 14 | contents: write 15 | strategy: 16 | matrix: 17 | include: 18 | - os: ubuntu-latest 19 | target: x86_64-linux 20 | - os: macos-latest 21 | target: x86_64-macos 22 | - os: macos-latest 23 | target: aarch64-macos 24 | 25 | steps: 26 | - uses: actions/checkout@v4 27 | with: 28 | fetch-depth: 0 29 | 30 | - uses: mlugg/setup-zig@v2.0.1 31 | with: 32 | version: 0.14.0 33 | 34 | - name: Install dependencies (Ubuntu) 35 | if: matrix.os == 'ubuntu-latest' 36 | run: | 37 | sudo apt-get update 38 | sudo apt-get install -y sqlite3 libsqlite3-dev libgit2-dev 39 | 40 | - name: Install dependencies (macOS) 41 | if: matrix.os == 'macos-latest' 42 | run: | 43 | brew install sqlite libgit2 44 | 45 | - run: zig build 46 | 47 | - run: zig build test 48 | 49 | - run: zig build release 50 | 51 | - name: Release 52 | if: github.ref_type == 'tag' 53 | uses: softprops/action-gh-release@v1 54 | with: 55 | draft: true 56 | generate_release_notes: true 57 | files: | 58 | zig-out/git-remote-sqlite-${{ matrix.target }}.tar.gz 59 | zig-out/git-remote-sqlite.db 60 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .zig-cache 2 | zig-out 3 | .claude 4 | -------------------------------------------------------------------------------- /CLAUDE.md: -------------------------------------------------------------------------------- 1 | # CLAUDE.md 2 | 3 | This file provides guidance to [Claude Code](https://claude.ai/code) when working with code in [this repository](https://github.com/chrislloyd/git-remote-sqlite). 4 | 5 | Refer to: 6 | 7 | * @docs/development.md 8 | * @docs/schema.md 9 | * @docs/style.md 10 | 11 | Some tips: 12 | 13 | * Any time you make something consistent, please document the decision in @docs/style.md. 14 | * Always write in a terse, to the point style. 15 | * The tests are fast and easy to run (`zig build test`) - always run them after refactoring. 16 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Copyright (c) 2025 Chris Lloyd 2 | 3 | Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: 4 | 5 | The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. 6 | 7 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 8 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # git-remote-sqlite 2 | 3 | [Latest release](https://github.com/chrislloyd/git-remote-sqlite/releases/latest) | [Changes](https://github.com/chrislloyd/git-remote-sqlite/commits/main) | [Source](https://github.com/chrislloyd/git-remote-sqlite) 4 | 5 | --- 6 | 7 | **git-remote-sqlite** is a [Git protocol helper](https://git-scm.com/docs/gitremote-helpers) that helps you store a Git repository in a [SQLite](https://www.sqlite.org) database. Why would you want to do this? 8 | 9 | 1. **Simple Git hosting**. Hosting git repositories typically involves using a third-party forge like [GitHub](https://github.com) or [Sourcehut](https://sourcehut.org). These primarily provide value by handling the storage, networking, and access control complexity of hosting Git repositories. At a smaller scale, **git-remote-sqlite** (combined with other tools like [Litestream](https://litestream.io)) may be cheaper and practically easy enough to use. 10 | 2. **Self-contained application bundle**. While **git-remote-sqlite** isn't as powerful as Lisp/Smalltalk-style images, it does allow an application's data to be distributed alongside its code. What can you do with this? I don't know but it sounds cool! 11 | 3. **(Advanced) More control over workflows**. Leveraging Git hooks lets you transactionally automate code changes. Think Erlang/OTP's `code_change/3` but more generic. 12 | 13 | ## Installation 14 | 15 | 1. Prerequisites: 16 | 17 | * [Git](https://git-scm.com) >= 1.6.6 18 | * [SQLite](https://sqlite.org) >= 3.0.0 19 | 20 | 2. Download and extract the latest release for your platform: 21 | 22 | ```bash 23 | # macOS (Apple Silicon) 24 | curl -L https://github.com/chrislloyd/git-remote-sqlite/releases/latest/download/git-remote-sqlite-aarch64-macos.tar.gz | tar xz 25 | 26 | # Linux (x86_64) 27 | curl -L https://github.com/chrislloyd/git-remote-sqlite/releases/latest/download/git-remote-sqlite-x86_64-linux.tar.gz | tar xz 28 | ``` 29 | 30 | 3. Move the binary to your `$PATH`: 31 | 32 | ```bash 33 | sudo mv git-remote-sqlite /usr/local/bin/ 34 | ``` 35 | 36 | ## Basic Usage 37 | 38 | ### 1. Push to/pull from the database 39 | 40 | When `git-remote-sqlite` is in your $PATH, you can push your code to a local SQLite database. If it doesn't exist, it'll be created: 41 | 42 | ```bash 43 | git push sqlite://myapp.db main 44 | ``` 45 | 46 | Pull it back: 47 | 48 | ```bash 49 | git pull sqlite://myapp.db main 50 | ``` 51 | 52 | All done! Fancy. 53 | 54 | ### 2. Configure Repository Settings 55 | 56 | You can configure server-side git settings. These don't currently affect any behavior. 57 | 58 | ```bash 59 | # Set configuration variables (similar to editing server-side git config) 60 | git-remote-sqlite config myapp.db receive.denyDeletes true 61 | git-remote-sqlite config myapp.db receive.denyNonFastForwards true 62 | 63 | # List all configured settings 64 | git-remote-sqlite config myapp.db --list 65 | 66 | # Get a specific setting value 67 | git-remote-sqlite config myapp.db --get receive.denyDeletes 68 | 69 | # Remove a setting 70 | git-remote-sqlite config myapp.db --unset receive.denyDeletes 71 | ``` 72 | 73 | ## How it works 74 | 75 | **git-remote-sqlite** stores Git objects (commits, trees, blobs) as rows in SQLite tables. When you push to `sqlite://myapp.db`, Git objects are inserted into the database. When you pull, they're read back out. 76 | 77 | The [database schema](docs/schema.md) includes: 78 | 79 | - `git_objects` - stores all Git objects with their SHA, type, and data 80 | - `git_refs` - tracks branches and tags 81 | - `git_symbolic_refs` - handles HEAD and other symbolic references 82 | 83 | Since it's just a SQLite database, you can query your repository with SQL, back it up with standard tools, or even replicate it with Litestream. 84 | 85 | ## FAQ 86 | 87 | ### Why not just use a bare Git repository? 88 | 89 | Bare repos work great for traditional hosting, but SQLite gives you: 90 | - Queryable data - find large objects, analyze commit patterns, or build 91 | custom workflows with SQL 92 | - Single-file deployment - one `.db` file instead of a directory tree 93 | - Replication options - tools like Litestream can continuously replicate 94 | SQLite to S3 95 | 96 | ### How does performance compare to regular Git? 97 | 98 | For small-to-medium repositories, performance is comparable. The SQLite 99 | overhead is minimal for most operations. However: 100 | - Large repositories with thousands of objects may be slower 101 | - Pack files aren't implemented yet, so storage is less efficient 102 | - Clone operations might be slower than optimized Git servers 103 | 104 | **git-remote-sqlite** is currently proitizing simplicity and trying new stuff over raw performance. 105 | 106 | ### Can I use this with existing Git workflows? 107 | 108 | Yes! **git-remote-sqlite** is a standard Git remote helper. You can: 109 | - Push/pull between SQLite and regular Git repos 110 | - Use it alongside other remotes (GitHub, GitLab, etc.) 111 | - Apply standard Git workflows (branches, merges, rebases) 112 | 113 | ### Is the database format stable? 114 | 115 | The schema is documented in [docs/schema.md](docs/schema.md). While I may 116 | add tables (like pack support), I'll try not to break existing tables without an automatic migration plan. 117 | 118 | ### What about security? 119 | 120 | **git-remote-sqlite** provides no authentication or access control - it's 121 | designed for local use or trusted environments. For remote access, you'd need 122 | to add your own security layer or use SQLite's built-in encryption 123 | extensions. 124 | 125 | ### Should I use this for my companies Git Repo? 126 | 127 | Probably not. **git-remote-sqlite** is not 1.0 yet. It _definitely_ has bugs, performance cliffs and unknown behavior that makes it unsuitable for anything other than disposable toys. 128 | 129 | ## TODO 130 | 131 | - [ ] Git hook support 132 | - [ ] Pack-file management 133 | - [ ] Full support for protocol commands 134 | - [ ] Performance profiling for large repos 135 | -------------------------------------------------------------------------------- /build.zig: -------------------------------------------------------------------------------- 1 | const std = @import("std"); 2 | const builtin = @import("builtin"); 3 | 4 | comptime { 5 | const expected_zig_version = std.SemanticVersion{ 6 | .major = 0, 7 | .minor = 14, 8 | .patch = 0, 9 | }; 10 | const actual_zig_version = builtin.zig_version; 11 | if (actual_zig_version.order(expected_zig_version).compare(.lt)) { 12 | @compileError(std.fmt.comptimePrint( 13 | "unsupported zig version: expected {}, found {}", 14 | .{ expected_zig_version, actual_zig_version }, 15 | )); 16 | } 17 | } 18 | 19 | pub fn build(b: *std.Build) void { 20 | const steps = .{ 21 | .run = b.step("run", "Run git-remote-sqlite"), 22 | .@"test" = b.step("test", "Run tests"), 23 | .test_unit = b.step("test:unit", "Run unit tests"), 24 | .test_integration = b.step("test:integration", "Run integration tests"), 25 | .release = b.step("release", "Build release archives for all platforms"), 26 | .repo_db = b.step("repo-db", "Create SQLite database of this repository"), 27 | }; 28 | 29 | const target = b.standardTargetOptions(.{}); 30 | const optimize = b.standardOptimizeOption(.{}); 31 | 32 | build_git_remote_sqlite(b, .{ 33 | .run = steps.run, 34 | .install = b.getInstallStep(), 35 | }, .{ .target = target, .optimize = optimize }); 36 | 37 | build_test(b, .{ .@"test" = steps.@"test", .test_unit = steps.test_unit, .test_integration = steps.test_integration }, .{ .target = target, .optimize = optimize }); 38 | 39 | build_release(b, steps.release, .{ .target = target }); 40 | 41 | build_repo_database(b, steps.repo_db); 42 | 43 | // Add repo database to release step 44 | steps.release.dependOn(steps.repo_db); 45 | } 46 | 47 | fn build_git_remote_sqlite(b: *std.Build, steps: struct { 48 | run: *std.Build.Step, 49 | install: *std.Build.Step, 50 | }, options: struct { 51 | target: std.Build.ResolvedTarget, 52 | optimize: std.builtin.OptimizeMode, 53 | }) void { 54 | // Create a module for our main entry point 55 | const main_mod = b.createModule(.{ 56 | .root_source_file = b.path("src/main.zig"), 57 | .target = options.target, 58 | .optimize = options.optimize, 59 | }); 60 | 61 | // Create an executable from our module 62 | const exe = b.addExecutable(.{ 63 | .name = "git-remote-sqlite", 64 | .root_module = main_mod, 65 | }); 66 | 67 | // Add system library linkage 68 | exe.linkSystemLibrary("sqlite3"); 69 | exe.linkSystemLibrary("git2"); 70 | exe.linkLibC(); 71 | 72 | // This declares intent for the executable to be installed into the 73 | // standard location when the user invokes the "install" step (the default 74 | // step when running `zig build`). 75 | b.installArtifact(exe); 76 | 77 | // This *creates* a Run step in the build graph, to be executed when another 78 | // step is evaluated that depends on it. The next line below will establish 79 | // such a dependency. 80 | const run_cmd = b.addRunArtifact(exe); 81 | 82 | // By making the run step depend on the install step, it will be run from the 83 | // installation directory rather than directly from within the cache directory. 84 | // This is not necessary, however, if the application depends on other installed 85 | // files, this ensures they will be present and in the expected location. 86 | run_cmd.step.dependOn(b.getInstallStep()); 87 | 88 | // This allows the user to pass arguments to the application in the build 89 | // command itself, like this: `zig build run -- arg1 arg2 etc` 90 | if (b.args) |args| { 91 | run_cmd.addArgs(args); 92 | } 93 | 94 | // This creates a build step. It will be visible in the `zig build --help` menu, 95 | // and can be selected like this: `zig build run` 96 | // This will evaluate the `run` step rather than the default, which is "install". 97 | // const run_step = b.step("run", "Run the app"); 98 | steps.run.dependOn(&run_cmd.step); 99 | } 100 | 101 | fn build_test(b: *std.Build, steps: struct { 102 | @"test": *std.Build.Step, 103 | test_unit: *std.Build.Step, 104 | test_integration: *std.Build.Step, 105 | }, options: struct { 106 | target: std.Build.ResolvedTarget, 107 | optimize: std.builtin.OptimizeMode, 108 | }) void { 109 | const unit_mod = b.createModule(.{ 110 | .root_source_file = b.path("src/tests.zig"), 111 | .target = options.target, 112 | .optimize = options.optimize, 113 | }); 114 | const unit = b.addTest(.{ 115 | .root_module = unit_mod, 116 | }); 117 | unit.linkSystemLibrary("sqlite3"); 118 | unit.linkSystemLibrary("git2"); 119 | unit.linkLibC(); 120 | 121 | const run_unit = b.addRunArtifact(unit); 122 | steps.test_unit.dependOn(&run_unit.step); 123 | 124 | // Integration tests - run the e2e script 125 | const integration_cmd = b.addSystemCommand(&[_][]const u8{ "bash", "src/e2e.sh", b.getInstallPath(.bin, "git-remote-sqlite") }); 126 | integration_cmd.step.dependOn(b.getInstallStep()); // Ensure binary is built first 127 | steps.test_integration.dependOn(&integration_cmd.step); 128 | 129 | // Main test step runs both unit and integration tests 130 | steps.@"test".dependOn(&run_unit.step); 131 | steps.@"test".dependOn(&integration_cmd.step); 132 | } 133 | 134 | fn build_release(b: *std.Build, release_step: *std.Build.Step, options: struct { 135 | target: std.Build.ResolvedTarget, 136 | }) void { 137 | // Use the target passed from build() 138 | const release_target = options.target; 139 | 140 | // Determine target triple name based on the actual target 141 | const triple = blk: { 142 | const arch = release_target.result.cpu.arch; 143 | const os = release_target.result.os.tag; 144 | 145 | if (arch == .x86_64 and os == .linux) break :blk "x86_64-linux"; 146 | if (arch == .x86_64 and os == .macos) break :blk "x86_64-macos"; 147 | if (arch == .aarch64 and os == .macos) break :blk "aarch64-macos"; 148 | 149 | // Fallback for other platforms 150 | break :blk b.fmt("{s}-{s}", .{ @tagName(arch), @tagName(os) }); 151 | }; 152 | 153 | const release_mod = b.createModule(.{ 154 | .root_source_file = b.path("src/main.zig"), 155 | .target = release_target, 156 | .optimize = .ReleaseSafe, 157 | }); 158 | const release_exe = b.addExecutable(.{ 159 | .name = "git-remote-sqlite", 160 | .root_module = release_mod, 161 | }); 162 | release_exe.linkSystemLibrary("sqlite3"); 163 | release_exe.linkSystemLibrary("git2"); 164 | release_exe.linkLibC(); 165 | 166 | // Install to zig-out/bin/{target}/git-remote-sqlite 167 | const install = b.addInstallArtifact(release_exe, .{ 168 | .dest_dir = .{ .override = .{ .custom = b.fmt("bin/{s}", .{triple}) } }, 169 | }); 170 | 171 | // Create tar.gz archive 172 | const tar_cmd = b.addSystemCommand(&[_][]const u8{ 173 | "tar", "-czf", 174 | b.fmt("{s}/git-remote-sqlite-{s}.tar.gz", .{ b.install_path, triple }), 175 | "-C", b.fmt("{s}/bin/{s}", .{ b.install_path, triple }), 176 | "git-remote-sqlite", 177 | }); 178 | tar_cmd.step.dependOn(&install.step); 179 | 180 | release_step.dependOn(&tar_cmd.step); 181 | } 182 | 183 | fn build_repo_database(b: *std.Build, repo_db_step: *std.Build.Step) void { 184 | // Ensure the binary is built first 185 | repo_db_step.dependOn(b.getInstallStep()); 186 | 187 | const db_filename = "git-remote-sqlite.db"; 188 | const db_path = b.fmt("{s}/{s}", .{ b.install_path, db_filename }); 189 | 190 | // Remove existing database if it exists 191 | const rm_cmd = b.addSystemCommand(&[_][]const u8{ "rm", "-f", db_path }); 192 | 193 | // Script to create the database 194 | const script = b.fmt( 195 | \\#!/bin/bash 196 | \\set -euo pipefail 197 | \\ 198 | \\# Remove existing database 199 | \\rm -f {s} 200 | \\ 201 | \\# Create temp directory 202 | \\TEMP_DIR=$(mktemp -d) 203 | \\trap "rm -rf $TEMP_DIR" EXIT 204 | \\ 205 | \\# Initialize bare repo in temp dir 206 | \\cd "$TEMP_DIR" 207 | \\git init --bare 208 | \\ 209 | \\# Configure the SQLite remote 210 | \\git remote add sqlite "sqlite://{s}" 211 | \\ 212 | \\# Push current repo to temp bare repo 213 | \\cd {s} 214 | \\git push --mirror "file://$TEMP_DIR" 215 | \\ 216 | \\# Push from temp repo to SQLite 217 | \\cd "$TEMP_DIR" 218 | \\export PATH="{s}/bin:$PATH" 219 | \\git push --mirror sqlite 220 | \\ 221 | \\# Verify the database was created 222 | \\if [ -f "{s}" ]; then 223 | \\ echo "Repository database created: {s}" 224 | \\ echo "Database size: $(du -h {s} | cut -f1)" 225 | \\ echo "Objects: $(sqlite3 {s} 'SELECT COUNT(*) FROM git_objects')" 226 | \\ echo "Refs: $(sqlite3 {s} 'SELECT COUNT(*) FROM git_refs')" 227 | \\else 228 | \\ echo "ERROR: Database was not created" 229 | \\ exit 1 230 | \\fi 231 | , .{ db_path, db_path, b.build_root.path orelse ".", b.install_path, db_path, db_path, db_path, db_path, db_path }); 232 | 233 | // Write and execute the script 234 | const script_path = b.fmt("{s}/create-repo-db.sh", .{b.cache_root.path orelse "."}); 235 | const write_script = b.addWriteFile(script_path, script); 236 | 237 | const create_db = b.addSystemCommand(&[_][]const u8{ "bash", script_path }); 238 | create_db.step.dependOn(&write_script.step); 239 | create_db.step.dependOn(&rm_cmd.step); 240 | 241 | repo_db_step.dependOn(&create_db.step); 242 | } 243 | -------------------------------------------------------------------------------- /build.zig.zon: -------------------------------------------------------------------------------- 1 | .{ 2 | .name = .git_remote_sqlite, 3 | .version = "0.0.0", 4 | .fingerprint = 0xbb484d695971656d, // Changing this has security and trust implications. 5 | .minimum_zig_version = "0.14.0", 6 | .dependencies = .{}, 7 | .paths = .{ 8 | "build.zig", 9 | "build.zig.zon", 10 | "src", 11 | "LICENSE", 12 | "README.md", 13 | }, 14 | } -------------------------------------------------------------------------------- /docs/development.md: -------------------------------------------------------------------------------- 1 | # Development 2 | 3 | ### Prerequisites 4 | 5 | * [Zig](https://ziglang.org) 0.14.0 6 | * [SQLite](https://www.sqlite.org) >= 3.0.0 7 | * [libgit2](https://libgit2.org) >= 1.0.0 8 | 9 | ## Building 10 | 11 | 1. Clone the repository: 12 | 13 | ```bash 14 | git clone https://github.com/chrislloyd/git-remote-sqlite.git 15 | cd git-remote-sqlite 16 | ``` 17 | 18 | 2. Build the binary using Zig: 19 | 20 | ```bash 21 | zig build 22 | ``` 23 | 24 | 3. Copy the binary to your path: 25 | 26 | ```bash 27 | sudo cp zig-out/bin/git-remote-sqlite /usr/local/bin/ 28 | ``` 29 | 30 | 4. The installation is complete. The binary functions as both a standalone command and as a Git remote helper. 31 | 32 | ## Testing 33 | 34 | ```shell 35 | zig build test # all 36 | zig build test:unit 37 | zig build test:e2e 38 | ``` 39 | -------------------------------------------------------------------------------- /docs/schema.md: -------------------------------------------------------------------------------- 1 | # Database Schema 2 | 3 | **git_objects**: Stores git objects (blobs, trees, commits, tags) 4 | 5 | | Column | Type | Constraints | Description | 6 | |--|--|--|--| 7 | | `sha` | TEXT | PRIMARY KEY, CHECK(length(sha) = 40 AND sha GLOB '[0-9a-f]*') | Object SHA hash | 8 | | `type` | TEXT | NOT NULL, CHECK(type IN ('blob', 'tree', 'commit', 'tag')) | Object type (blob, tree, commit, tag) | 9 | | `data` | BLOB | NOT NULL | Object content | 10 | 11 | *Indexes:* 12 | - `CREATE INDEX idx_git_objects_type ON git_objects(type);` - For efficient queries by object type 13 | 14 | **git_refs**: Stores git references 15 | 16 | | Column | Type | Constraints | Description | 17 | |--|--|--|--| 18 | | `name` | TEXT | PRIMARY KEY, CHECK(name GLOB 'refs/*') | Reference name (e.g., 'refs/heads/main') | 19 | | `sha` | TEXT | NOT NULL, FOREIGN KEY REFERENCES git_objects(sha) | Commit SHA the ref points to | 20 | | `type` | TEXT | NOT NULL, CHECK(type IN ('branch', 'tag', 'remote')) | Reference type (branch, tag, remote) | 21 | 22 | *Indexes:* 23 | - `CREATE INDEX idx_git_refs_sha ON git_refs(sha);` - For finding all refs pointing to a specific commit 24 | 25 | **git_symbolic_refs**: Stores symbolic references (like HEAD) 26 | 27 | | Column | Type | Constraints | Description | 28 | |--|--|--|--| 29 | | `name` | TEXT | PRIMARY KEY | Symbolic reference name (e.g., 'HEAD') | 30 | | `target` | TEXT | NOT NULL, FOREIGN KEY REFERENCES git_refs(name) | Target reference path | 31 | 32 | **git_packs**: Stores git pack files *(pending implementation)* 33 | 34 | | Column | Type | Constraints | Description | 35 | |--|--|--|--| 36 | | `id` | INTEGER | PRIMARY KEY | Unique pack identifier | 37 | | `name` | TEXT | NOT NULL, UNIQUE | Pack name/identifier | 38 | | `data` | BLOB | NOT NULL | Pack file binary data | 39 | | `index_data` | BLOB | NOT NULL | Pack index binary data | 40 | 41 | *Indexes:* 42 | - `CREATE INDEX idx_git_packs_name ON git_packs(name);` - For efficient lookups by pack name 43 | 44 | **git_pack_entries**: Maps objects to packs for faster lookups *(pending implementation)* 45 | 46 | | Column | Type | Constraints | Description | 47 | |--|--|--|--| 48 | | `pack_id` | INTEGER | NOT NULL, FOREIGN KEY REFERENCES git_packs(id) | Reference to the pack | 49 | | `sha` | TEXT | NOT NULL, FOREIGN KEY REFERENCES git_objects(sha) | Object SHA contained in the pack | 50 | | `offset` | INTEGER | NOT NULL | Offset position within the pack | 51 | | PRIMARY KEY | | (pack_id, sha) | Composite primary key | 52 | 53 | *Indexes:* 54 | - `CREATE INDEX idx_git_pack_entries_sha ON git_pack_entries(sha);` - For finding which pack contains a specific object 55 | 56 | 57 | **git_config**: Stores git configuration settings 58 | 59 | | Column | Type | Constraints | Description | 60 | |--|--|--|--| 61 | | `key` | TEXT | PRIMARY KEY | Configuration key | 62 | | `value` | TEXT | NOT NULL | Configuration value | -------------------------------------------------------------------------------- /docs/style.md: -------------------------------------------------------------------------------- 1 | # Style Guide 2 | 3 | Coding conventions for git-remote-sqlite. 4 | 5 | ## Files 6 | 7 | File names should be simple and descriptive: `main.zig`, `git.zig`, `sqlite.zig`. Each file should have a single responsibility and act as a namespace. Don't bury the lede - organize files so that the most important functions and declarations are at the top. 8 | 9 | Separate public and private functions clearly. Order imports with standard library first, then relative imports, then C imports last. Within each section, organize alphabetically. 10 | 11 | ```zig 12 | const std = @import("std"); // Standard library first 13 | const config = @import("./config.zig"); // Local imports 14 | const c = @cImport({ @cInclude("header.h") }); // C imports last 15 | ``` 16 | 17 | ## Naming Conventions 18 | 19 | Functions use **camelCase**: `parseCommand()`, `writeResponse()`, `handleCapabilities()`. 20 | 21 | Variables use **snake_case**: `null_terminated_path`, `parsed_url`, `object_data`. 22 | 23 | Types and structs use **PascalCase**: `RemoteUrl`, `GitError`, `ObjectData`. Make struct names descriptive: `ParsedRefspec`, `GitObjectWriter`, `ProgressMessage`. Use tagged unions for variants: `Command`, `Response`, `Result`. 24 | 25 | ## Function Design 26 | 27 | Be explicit over implicit - make intentions clear in code. Follow single responsibility - each function and module should have one job. Practice resource safety by always cleaning up resources, especially in error cases. 28 | 29 | Write documentation as you write code. Use test-driven development to validate expected behavior. 30 | 31 | Functions should do one thing well. Don't provide default arguments - prefer that callsites always provide defaults. 32 | 33 | Try to keep lists of things (struct keys, switch cases) sorted alphabetically. 34 | 35 | Use `@""` syntax for reserved words rather than alternatives. Leverage comptime for validation where appropriate. 36 | 37 | ## Testing 38 | 39 | Keep unit tests in the same file as implementation when possible. Each test should have a single, clear purpose. Err on fewer high-signal tests than lots of low-value tests - 100% coverage isn't the goal. 40 | 41 | Always have an e2e test for any command or behavior claimed in the README. 42 | 43 | ## Comments 44 | 45 | Use `// --` for section breaks in files. This keeps visual noise low while providing clear section delineation. 46 | -------------------------------------------------------------------------------- /src/cli.zig: -------------------------------------------------------------------------------- 1 | const std = @import("std"); 2 | const config = @import("config.zig"); 3 | const remote = @import("remote.zig"); 4 | const help = @import("help.zig"); 5 | const cmd = @import("cmd.zig"); 6 | 7 | // --- 8 | 9 | pub fn run(allocator: std.mem.Allocator, process: cmd.Process) !void { 10 | 11 | // Check if called as git-remote-sqlite (remote helper mode) 12 | const program_name = std.fs.path.basename(process.argv[0]); 13 | if (std.mem.endsWith(u8, program_name, "git-remote-sqlite") and process.argv.len >= 3) { 14 | if (std.mem.indexOf(u8, process.argv[2], "://") != null) { 15 | const remote_process = cmd.Process{ 16 | .argv = process.argv[1..], 17 | .stdin = process.stdin, 18 | .stdout = process.stdout, 19 | .stderr = process.stderr, 20 | .env = process.env, 21 | }; 22 | try remote.run(allocator, remote_process); 23 | return; 24 | } 25 | } 26 | 27 | // --- 28 | 29 | if (process.argv.len < 2) { 30 | try help.run(allocator, process); 31 | return; 32 | } 33 | 34 | const command_str = process.argv[1]; 35 | 36 | if (std.mem.eql(u8, command_str, "config")) { 37 | const config_process = cmd.Process{ 38 | .argv = process.argv[2..], 39 | .stdin = process.stdin, 40 | .stdout = process.stdout, 41 | .stderr = process.stderr, 42 | .env = process.env, 43 | }; 44 | 45 | config.run(allocator, config_process) catch |err| { 46 | try process.stderr.print("Error: {}\n", .{err}); 47 | std.process.exit(1); 48 | }; 49 | } else { 50 | try help.run(allocator, process); 51 | } 52 | } -------------------------------------------------------------------------------- /src/cmd.zig: -------------------------------------------------------------------------------- 1 | const std = @import("std"); 2 | 3 | pub const Process = struct { 4 | argv: []const []const u8, 5 | stdin: std.io.AnyReader, 6 | stdout: std.io.AnyWriter, 7 | stderr: std.io.AnyWriter, 8 | env: std.process.EnvMap, 9 | }; 10 | -------------------------------------------------------------------------------- /src/config.zig: -------------------------------------------------------------------------------- 1 | const std = @import("std"); 2 | const sqlite = @import("sqlite.zig"); 3 | const cmd = @import("cmd.zig"); 4 | 5 | pub const ConfigError = error{ 6 | InvalidArgs, 7 | KeyNotFound, 8 | OutOfMemory, 9 | DatabaseError, 10 | AllocationError, 11 | }; 12 | 13 | // --- 14 | 15 | pub fn run(allocator: std.mem.Allocator, process: cmd.Process) !void { 16 | _ = process.stdin; 17 | 18 | if (process.argv.len < 1) { 19 | return ConfigError.InvalidArgs; 20 | } 21 | 22 | const db_path = process.argv[0]; 23 | const null_terminated_path = try allocator.dupeZ(u8, db_path); 24 | defer allocator.free(null_terminated_path); 25 | 26 | var db = sqlite.Database.open(allocator, null_terminated_path) catch { 27 | return ConfigError.DatabaseError; 28 | }; 29 | defer db.close(); 30 | 31 | var config_store = sqlite.ConfigDatabase.init(&db) catch { 32 | return ConfigError.DatabaseError; 33 | }; 34 | 35 | if (process.argv.len == 1) { 36 | const entries = try config_store.iterateConfig(allocator); 37 | defer { 38 | for (entries) |entry| { 39 | entry.deinit(allocator); 40 | } 41 | allocator.free(entries); 42 | } 43 | 44 | for (entries) |entry| { 45 | try process.stdout.print("{s}={s}\n", .{ entry.key, entry.value }); 46 | } 47 | 48 | return; 49 | } 50 | 51 | const subcommand = process.argv[1]; 52 | if (std.mem.eql(u8, subcommand, "--list")) { 53 | const entries = try config_store.iterateConfig(allocator); 54 | defer { 55 | for (entries) |entry| { 56 | entry.deinit(allocator); 57 | } 58 | allocator.free(entries); 59 | } 60 | 61 | for (entries) |entry| { 62 | try process.stdout.print("{s}={s}\n", .{ entry.key, entry.value }); 63 | } 64 | } else if (std.mem.eql(u8, subcommand, "--get")) { 65 | if (process.argv.len < 3) { 66 | return ConfigError.InvalidArgs; 67 | } 68 | const value = try config_store.readConfig(allocator, process.argv[2]); 69 | if (value) |v| { 70 | defer allocator.free(v); 71 | try process.stdout.print("{s}", .{v}); 72 | } else { 73 | return ConfigError.KeyNotFound; 74 | } 75 | } else if (std.mem.eql(u8, subcommand, "--unset")) { 76 | if (process.argv.len < 3) { 77 | return ConfigError.InvalidArgs; 78 | } 79 | try config_store.unsetConfig(process.argv[2]); 80 | } else if (process.argv.len >= 3) { 81 | try config_store.writeConfig(process.argv[1], process.argv[2]); 82 | } else { 83 | return ConfigError.InvalidArgs; 84 | } 85 | } 86 | -------------------------------------------------------------------------------- /src/e2e.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | # End-to-end test for git-remote-sqlite 3 | # Tests basic workflows described in the README 4 | 5 | set -e 6 | set -u 7 | set -o pipefail 8 | 9 | # Colors for output 10 | GREEN='\033[0;32m' 11 | RED='\033[0;31m' 12 | NC='\033[0m' # No Color 13 | 14 | # Test directory setup - use system tmpdir 15 | TEST_DIR=$(mktemp -d) 16 | echo "Using temporary test directory: $TEST_DIR" 17 | 18 | # Helper functions 19 | pass() { 20 | echo -e "${GREEN}✓ $1${NC}" 21 | } 22 | 23 | fail() { 24 | echo -e "${RED}✗ $1${NC}" 25 | exit 1 26 | } 27 | 28 | # Function to print debug info 29 | debug_output() { 30 | echo "Debug output:" 31 | echo "---" 32 | cat "$1" 33 | echo "---" 34 | } 35 | 36 | # Setup function - handles binary path and PATH configuration 37 | setup() { 38 | # If binary path provided as argument, add its directory to PATH 39 | if [ $# -gt 0 ]; then 40 | BINARY_PATH="$1" 41 | echo "Using binary path from argument: $BINARY_PATH" 42 | 43 | echo "Using binary at $BINARY_PATH" 44 | 45 | # Add the binary directory to PATH so git can find git-remote-sqlite 46 | BINARY_DIR=$(dirname "$BINARY_PATH") 47 | export PATH="${BINARY_DIR}:$PATH" 48 | echo "Added to PATH: $BINARY_DIR" 49 | else 50 | echo "Using git-remote-sqlite from PATH" 51 | fi 52 | 53 | # Always use the command name for execution 54 | GIT_REMOTE_SQLITE="git-remote-sqlite" 55 | 56 | # Verify git can find our remote helper 57 | if ! which git-remote-sqlite >/dev/null 2>&1; then 58 | fail "git-remote-sqlite not found in PATH" 59 | fi 60 | echo "Verified git-remote-sqlite is in PATH" 61 | 62 | cd "$TEST_DIR" 63 | } 64 | 65 | teardown() { 66 | echo "Cleaning up test directory" 67 | cd / 68 | rm -rf "$TEST_DIR" 69 | } 70 | 71 | trap teardown EXIT 72 | 73 | test_push() { 74 | echo "Test: Create and push a repository..." 75 | 76 | # Create a test repo 77 | mkdir -p test_repo 78 | cd test_repo 79 | git init . 80 | git config user.name "Test User" 81 | git config user.email "test@example.com" 82 | git config commit.gpgsign false 83 | git config init.defaultBranch main 84 | 85 | # Create a test file 86 | echo "# Test Repository" > README.md 87 | echo "This is a test file." >> README.md 88 | 89 | # Commit the file 90 | git add README.md 91 | git commit -m "Initial commit" 92 | 93 | # Ensure we're on main branch (rename master to main if needed) 94 | git branch -M main 95 | 96 | # Add the database as a remote and push 97 | git remote add origin "sqlite://${TEST_DIR}/test.db" 98 | 99 | # Run git push 100 | if ! git push origin main; then 101 | fail "Failed to push to SQLite database" 102 | fi 103 | 104 | pass "Successfully pushed to database" 105 | cd "$TEST_DIR" 106 | return 0 107 | } 108 | 109 | test_clone() { 110 | echo "Test: Clone from the database..." 111 | 112 | # Create a new directory to clone into 113 | mkdir -p clone_test 114 | cd clone_test 115 | 116 | # Initialize a new repository 117 | git init . 118 | git config user.name "Test User" 119 | git config user.email "test@example.com" 120 | git config commit.gpgsign false 121 | git config init.defaultBranch main 122 | 123 | # Add the database as a remote and fetch 124 | git remote add origin "sqlite://${TEST_DIR}/test.db" 125 | 126 | if ! git fetch origin; then 127 | fail "Failed to fetch from SQLite database" 128 | fi 129 | 130 | git checkout -b main origin/main || fail "Failed to checkout branch from SQLite database" 131 | 132 | # Verify the content exists and matches exactly what was pushed 133 | [ -f README.md ] || fail "README.md not found in cloned repository" 134 | 135 | # Compare the cloned file with the original 136 | diff README.md "${TEST_DIR}/test_repo/README.md" || fail "Cloned README.md does not match original" 137 | 138 | # Verify specific content 139 | grep -q "# Test Repository" README.md || fail "README.md missing header" 140 | grep -q "This is a test file." README.md || fail "README.md missing content" 141 | 142 | # Check git log matches 143 | original_commit=$(cd "${TEST_DIR}/test_repo" && git rev-parse HEAD) 144 | cloned_commit=$(git rev-parse HEAD) 145 | [ "$original_commit" = "$cloned_commit" ] || fail "Commit hashes don't match between original and clone" 146 | 147 | pass "Successfully cloned from database with matching content" 148 | cd "$TEST_DIR" 149 | return 0 150 | } 151 | 152 | test_config() { 153 | echo "Test: Repository configuration..." 154 | 155 | # Test config options mentioned in README.md 156 | echo "Testing README.md config options..." 157 | 158 | # Test receive.denyDeletes 159 | "$GIT_REMOTE_SQLITE" config test.db receive.denyDeletes true || fail "Failed to set receive.denyDeletes" 160 | config_value=$("$GIT_REMOTE_SQLITE" config test.db --get receive.denyDeletes) 161 | config_value=$(echo "$config_value" | tr -d '\n') 162 | [ "$config_value" = "true" ] || fail "receive.denyDeletes value not set correctly: '$config_value'" 163 | 164 | # Test receive.denyNonFastForwards 165 | "$GIT_REMOTE_SQLITE" config test.db receive.denyNonFastForwards true || fail "Failed to set receive.denyNonFastForwards" 166 | config_value=$("$GIT_REMOTE_SQLITE" config test.db --get receive.denyNonFastForwards) 167 | config_value=$(echo "$config_value" | tr -d '\n') 168 | [ "$config_value" = "true" ] || fail "receive.denyNonFastForwards value not set correctly: '$config_value'" 169 | 170 | # Test basic CRUD operations 171 | echo "Testing basic config operations..." 172 | 173 | # Test --list functionality 174 | "$GIT_REMOTE_SQLITE" config test.db --list > config_list.txt 175 | grep -q "receive.denyDeletes=true" config_list.txt || fail "--list missing receive.denyDeletes" 176 | grep -q "receive.denyNonFastForwards=true" config_list.txt || fail "--list missing receive.denyNonFastForwards" 177 | 178 | # Test --unset functionality 179 | "$GIT_REMOTE_SQLITE" config test.db --unset receive.denyNonFastForwards || fail "Failed to unset receive.denyNonFastForwards" 180 | 181 | # Verify it's gone 182 | if "$GIT_REMOTE_SQLITE" config test.db --get receive.denyNonFastForwards 2>/dev/null; then 183 | fail "receive.denyNonFastForwards should have been unset but still exists" 184 | fi 185 | 186 | # Test overwriting existing values 187 | "$GIT_REMOTE_SQLITE" config test.db receive.denyDeletes false || fail "Failed to overwrite receive.denyDeletes" 188 | config_value=$("$GIT_REMOTE_SQLITE" config test.db --get receive.denyDeletes) 189 | [ "$(echo "$config_value" | tr -d '\n')" = "false" ] || fail "receive.denyDeletes overwrite failed" 190 | 191 | # Test error cases 192 | echo "Testing error cases..." 193 | 194 | # Test --get for non-existent key 195 | if "$GIT_REMOTE_SQLITE" config test.db --get nonexistent.key 2>/dev/null; then 196 | fail "Should fail when getting non-existent key" 197 | fi 198 | 199 | pass "Successfully tested configuration functionality" 200 | return 0 201 | } 202 | 203 | test_update() { 204 | echo "Test: Update repository and verify synchronization..." 205 | 206 | # Go back to the test repo 207 | cd "$TEST_DIR/test_repo" 208 | 209 | # Make changes 210 | echo "This line was added in an update." >> README.md 211 | echo "## New Section" >> README.md 212 | echo "Additional content for testing." >> README.md 213 | git add README.md 214 | git commit -m "Update README.md with new content" 215 | 216 | # Push changes 217 | if ! git push origin main; then 218 | fail "Failed to push updates" 219 | fi 220 | 221 | # Go to clone and pull 222 | cd "$TEST_DIR/clone_test" 223 | 224 | # Pull changes 225 | if ! git pull origin main; then 226 | fail "Failed to pull updates" 227 | fi 228 | 229 | # Verify the updated files are identical 230 | diff README.md "${TEST_DIR}/test_repo/README.md" || fail "Updated README.md files don't match" 231 | 232 | # Verify all expected content is present 233 | grep -q "# Test Repository" README.md || fail "Original header missing after update" 234 | grep -q "This is a test file." README.md || fail "Original content missing after update" 235 | grep -q "This line was added in an update." README.md || fail "Updated content not found in clone" 236 | grep -q "## New Section" README.md || fail "New section missing in clone" 237 | grep -q "Additional content for testing." README.md || fail "Additional content missing in clone" 238 | 239 | # Verify commit history matches 240 | original_commit=$(cd "${TEST_DIR}/test_repo" && git rev-parse HEAD) 241 | cloned_commit=$(git rev-parse HEAD) 242 | [ "$original_commit" = "$cloned_commit" ] || fail "Commit hashes don't match after update" 243 | 244 | # Verify commit count matches 245 | original_count=$(cd "${TEST_DIR}/test_repo" && git rev-list --count HEAD) 246 | cloned_count=$(git rev-list --count HEAD) 247 | [ "$original_count" = "$cloned_count" ] || fail "Commit counts don't match: original=$original_count, clone=$cloned_count" 248 | 249 | pass "Successfully tested repository updates with verified content synchronization" 250 | cd "$TEST_DIR" 251 | return 0 252 | } 253 | 254 | # TODO: High signal config tests to add: 255 | # - Test that receive.denyDeletes actually prevents branch/tag deletions during push 256 | # - Test that receive.denyNonFastForwards prevents non-fast-forward pushes 257 | # - Test config persistence across multiple git operations 258 | # - Test config inheritance and precedence in git remote operations 259 | 260 | # Main test function 261 | run_tests() { 262 | echo "Starting end-to-end tests for git-remote-sqlite..." 263 | 264 | test_config 265 | test_push 266 | test_clone 267 | test_update 268 | 269 | echo -e "\n${GREEN}All tests passed successfully!${NC}" 270 | return 0 271 | } 272 | 273 | # Run all tests 274 | setup "$@" 275 | run_tests 276 | -------------------------------------------------------------------------------- /src/git.zig: -------------------------------------------------------------------------------- 1 | const std = @import("std"); 2 | const assert = std.debug.assert; 3 | 4 | // --- 5 | 6 | pub const c = @cImport({ 7 | @cInclude("git2.h"); 8 | }); 9 | 10 | comptime { 11 | const expected_git2_version = std.SemanticVersion{ 12 | .major = 1, 13 | .minor = 0, 14 | .patch = 0, 15 | }; 16 | const actual_git2_version = std.SemanticVersion{ 17 | .major = c.LIBGIT2_VER_MAJOR, 18 | .minor = c.LIBGIT2_VER_MINOR, 19 | .patch = c.LIBGIT2_VER_PATCH, 20 | }; 21 | if (actual_git2_version.order(expected_git2_version).compare(.lt)) { 22 | @compileError(std.fmt.comptimePrint("unsupported git2 version: expected {}, found {}", .{ expected_git2_version, actual_git2_version })); 23 | } 24 | } 25 | 26 | // --- 27 | 28 | pub const GitError = error{ 29 | InitFailed, 30 | RepoOpenFailed, 31 | RefResolveFailed, 32 | ObjectLookupFailed, 33 | RevwalkFailed, 34 | ObjectReadFailed, 35 | InvalidObjectType, 36 | RefspecParseFailed, 37 | OutOfMemory, 38 | }; 39 | 40 | // --- 41 | 42 | fn initLibgit2() void { 43 | if (c.git_libgit2_init() < 0) { 44 | std.debug.panic("Failed to initialize libgit2", .{}); 45 | } 46 | } 47 | 48 | var git2_init = std.once(initLibgit2); 49 | 50 | pub fn init() void { 51 | git2_init.call(); 52 | } 53 | 54 | // --- 55 | 56 | pub const RawRepository = struct { 57 | repo: *c.git_repository, 58 | 59 | pub fn open(path: [:0]const u8) GitError!RawRepository { 60 | var repo: ?*c.git_repository = null; 61 | if (c.git_repository_open(&repo, path.ptr) < 0) { 62 | return GitError.RepoOpenFailed; 63 | } 64 | return RawRepository{ .repo = repo.? }; 65 | } 66 | 67 | pub fn deinit(self: *RawRepository) void { 68 | c.git_repository_free(self.repo); 69 | } 70 | 71 | pub fn getOdb(self: *RawRepository) GitError!*c.git_odb { 72 | var odb: ?*c.git_odb = null; 73 | if (c.git_repository_odb(&odb, self.repo) < 0) { 74 | return GitError.ObjectLookupFailed; 75 | } 76 | return odb.?; 77 | } 78 | }; 79 | 80 | pub const RawOid = struct { 81 | oid: c.git_oid, 82 | 83 | pub fn fromStr(oid_str: []const u8) GitError!RawOid { 84 | assert(oid_str.len == 40); 85 | var oid: c.git_oid = undefined; 86 | if (c.git_oid_fromstr(&oid, oid_str.ptr) < 0) { 87 | return GitError.ObjectLookupFailed; 88 | } 89 | return RawOid{ .oid = oid }; 90 | } 91 | 92 | pub fn toString(self: *const RawOid, buf: *[41]u8) void { 93 | _ = c.git_oid_tostr(buf, buf.len, &self.oid); 94 | } 95 | }; 96 | 97 | pub const RawOdbObject = struct { 98 | obj: *c.git_odb_object, 99 | 100 | pub fn read(odb: *c.git_odb, oid: *const RawOid) GitError!RawOdbObject { 101 | var obj: ?*c.git_odb_object = null; 102 | if (c.git_odb_read(&obj, odb, &oid.oid) < 0) { 103 | return GitError.ObjectLookupFailed; 104 | } 105 | return RawOdbObject{ .obj = obj.? }; 106 | } 107 | 108 | pub fn deinit(self: *RawOdbObject) void { 109 | c.git_odb_object_free(self.obj); 110 | } 111 | 112 | pub fn getData(self: *const RawOdbObject) []const u8 { 113 | const data = c.git_odb_object_data(self.obj); 114 | const size = c.git_odb_object_size(self.obj); 115 | return @as([*]const u8, @ptrCast(data))[0..size]; 116 | } 117 | 118 | pub fn getType(self: *const RawOdbObject) []const u8 { 119 | return switch (c.git_odb_object_type(self.obj)) { 120 | c.GIT_OBJECT_BLOB => "blob", 121 | c.GIT_OBJECT_TREE => "tree", 122 | c.GIT_OBJECT_COMMIT => "commit", 123 | c.GIT_OBJECT_TAG => "tag", 124 | else => "unknown", 125 | }; 126 | } 127 | }; 128 | 129 | pub const RawRevwalk = struct { 130 | walker: *c.git_revwalk, 131 | 132 | pub fn new(repo: *RawRepository) GitError!RawRevwalk { 133 | var walker: ?*c.git_revwalk = null; 134 | if (c.git_revwalk_new(&walker, repo.repo) < 0) { 135 | return GitError.RevwalkFailed; 136 | } 137 | return RawRevwalk{ .walker = walker.? }; 138 | } 139 | 140 | pub fn deinit(self: *RawRevwalk) void { 141 | c.git_revwalk_free(self.walker); 142 | } 143 | 144 | pub fn push(self: *RawRevwalk, oid: *const RawOid) GitError!void { 145 | if (c.git_revwalk_push(self.walker, &oid.oid) < 0) { 146 | return GitError.RevwalkFailed; 147 | } 148 | } 149 | 150 | pub fn next(self: *RawRevwalk) ?RawOid { 151 | var oid: c.git_oid = undefined; 152 | if (c.git_revwalk_next(&oid, self.walker) == 0) { 153 | return RawOid{ .oid = oid }; 154 | } 155 | return null; 156 | } 157 | }; 158 | 159 | pub const RawCommit = struct { 160 | commit: *c.git_commit, 161 | 162 | pub fn lookup(repo: *RawRepository, oid: *const RawOid) GitError!RawCommit { 163 | var commit: ?*c.git_commit = null; 164 | if (c.git_commit_lookup(&commit, repo.repo, &oid.oid) < 0) { 165 | return GitError.ObjectLookupFailed; 166 | } 167 | return RawCommit{ .commit = commit.? }; 168 | } 169 | 170 | pub fn deinit(self: *RawCommit) void { 171 | c.git_commit_free(self.commit); 172 | } 173 | 174 | pub fn getTree(self: *RawCommit) GitError!RawTree { 175 | var tree: ?*c.git_tree = null; 176 | if (c.git_commit_tree(&tree, self.commit) < 0) { 177 | return GitError.ObjectLookupFailed; 178 | } 179 | return RawTree{ .tree = tree.? }; 180 | } 181 | }; 182 | 183 | pub const RawTree = struct { 184 | tree: *c.git_tree, 185 | 186 | pub fn lookup(repo: *RawRepository, oid: *const RawOid) GitError!RawTree { 187 | var tree: ?*c.git_tree = null; 188 | if (c.git_tree_lookup(&tree, repo.repo, &oid.oid) < 0) { 189 | return GitError.ObjectLookupFailed; 190 | } 191 | return RawTree{ .tree = tree.? }; 192 | } 193 | 194 | pub fn deinit(self: *RawTree) void { 195 | c.git_tree_free(self.tree); 196 | } 197 | 198 | pub fn getId(self: *const RawTree) RawOid { 199 | const oid = c.git_tree_id(self.tree); 200 | return RawOid{ .oid = oid.* }; 201 | } 202 | 203 | pub fn entryCount(self: *const RawTree) usize { 204 | return c.git_tree_entrycount(self.tree); 205 | } 206 | 207 | pub fn getEntryByIndex(self: *const RawTree, index: usize) ?RawTreeEntry { 208 | if (c.git_tree_entry_byindex(self.tree, index)) |entry| { 209 | return RawTreeEntry{ .entry = entry }; 210 | } 211 | return null; 212 | } 213 | }; 214 | 215 | pub const RawTreeEntry = struct { 216 | entry: *const c.git_tree_entry, 217 | 218 | pub fn getId(self: *const RawTreeEntry) RawOid { 219 | const oid = c.git_tree_entry_id(self.entry); 220 | return RawOid{ .oid = oid.* }; 221 | } 222 | 223 | pub fn getType(self: *const RawTreeEntry) c_int { 224 | return c.git_tree_entry_type(self.entry); 225 | } 226 | 227 | pub fn isTree(self: *const RawTreeEntry) bool { 228 | return self.getType() == c.GIT_OBJECT_TREE; 229 | } 230 | }; 231 | 232 | pub const RawRefspec = struct { 233 | refspec: *c.git_refspec, 234 | 235 | pub fn parse(refspec_str: [:0]const u8, is_fetch: bool) GitError!RawRefspec { 236 | var refspec: ?*c.git_refspec = null; 237 | const direction = if (is_fetch) c.GIT_DIRECTION_FETCH else c.GIT_DIRECTION_PUSH; 238 | if (c.git_refspec_parse(&refspec, refspec_str.ptr, direction) < 0) { 239 | return GitError.RefspecParseFailed; 240 | } 241 | return RawRefspec{ .refspec = refspec.? }; 242 | } 243 | 244 | pub fn deinit(self: *RawRefspec) void { 245 | c.git_refspec_free(self.refspec); 246 | } 247 | 248 | pub fn getSource(self: *const RawRefspec) []const u8 { 249 | const src_ptr = c.git_refspec_src(self.refspec); 250 | return std.mem.span(src_ptr); 251 | } 252 | 253 | pub fn getDestination(self: *const RawRefspec) []const u8 { 254 | const dst_ptr = c.git_refspec_dst(self.refspec); 255 | return std.mem.span(dst_ptr); 256 | } 257 | 258 | pub fn isForce(self: *const RawRefspec) bool { 259 | return c.git_refspec_force(self.refspec) != 0; 260 | } 261 | 262 | pub fn srcMatches(self: *const RawRefspec, ref_name: [:0]const u8) bool { 263 | return c.git_refspec_src_matches(self.refspec, ref_name.ptr) != 0; 264 | } 265 | 266 | pub fn dstMatches(self: *const RawRefspec, ref_name: [:0]const u8) bool { 267 | return c.git_refspec_dst_matches(self.refspec, ref_name.ptr) != 0; 268 | } 269 | }; 270 | 271 | pub fn resolveRefRaw(repo: *RawRepository, ref_name: [:0]const u8) GitError!RawOid { 272 | var oid: c.git_oid = undefined; 273 | if (c.git_reference_name_to_id(&oid, repo.repo, ref_name.ptr) < 0) { 274 | return GitError.RefResolveFailed; 275 | } 276 | return RawOid{ .oid = oid }; 277 | } 278 | 279 | pub fn writeObjectRaw(repo: *RawRepository, obj_type: []const u8, data: []const u8) GitError!RawOid { 280 | const odb = repo.getOdb() catch return GitError.ObjectReadFailed; 281 | defer c.git_odb_free(odb); 282 | 283 | const type_id = if (std.mem.eql(u8, obj_type, "commit")) 284 | c.GIT_OBJECT_COMMIT 285 | else if (std.mem.eql(u8, obj_type, "tree")) 286 | c.GIT_OBJECT_TREE 287 | else if (std.mem.eql(u8, obj_type, "blob")) 288 | c.GIT_OBJECT_BLOB 289 | else if (std.mem.eql(u8, obj_type, "tag")) 290 | c.GIT_OBJECT_TAG 291 | else 292 | return GitError.InvalidObjectType; 293 | 294 | var oid: c.git_oid = undefined; 295 | if (c.git_odb_write(&oid, odb, data.ptr, data.len, type_id) < 0) { 296 | return GitError.ObjectReadFailed; 297 | } 298 | 299 | return RawOid{ .oid = oid }; 300 | } 301 | 302 | // --- 303 | 304 | pub const Repository = struct { 305 | raw: RawRepository, 306 | allocator: std.mem.Allocator, 307 | 308 | pub fn open(allocator: std.mem.Allocator, path: []const u8) GitError!Repository { 309 | assert(path.len > 0); 310 | const null_path = allocator.dupeZ(u8, path) catch return GitError.RepoOpenFailed; 311 | defer allocator.free(null_path); 312 | 313 | const raw = try RawRepository.open(null_path); 314 | return Repository{ 315 | .raw = raw, 316 | .allocator = allocator, 317 | }; 318 | } 319 | 320 | pub fn deinit(self: *Repository) void { 321 | self.raw.deinit(); 322 | } 323 | 324 | pub fn resolveRef(self: *Repository, ref_name: []const u8) GitError![]const u8 { 325 | assert(ref_name.len > 0); 326 | const null_ref = self.allocator.dupeZ(u8, ref_name) catch return GitError.RefResolveFailed; 327 | defer self.allocator.free(null_ref); 328 | 329 | const oid = try resolveRefRaw(&self.raw, null_ref); 330 | var oid_str: [41]u8 = undefined; 331 | oid.toString(&oid_str); 332 | return self.allocator.dupe(u8, oid_str[0..40]) catch return GitError.RefResolveFailed; 333 | } 334 | 335 | pub fn getObjectData(self: *Repository, oid_str: []const u8) GitError!ObjectData { 336 | assert(oid_str.len == 40); 337 | const oid = try RawOid.fromStr(oid_str); 338 | const odb = try self.raw.getOdb(); 339 | defer c.git_odb_free(odb); 340 | 341 | var raw_obj = try RawOdbObject.read(odb, &oid); 342 | defer raw_obj.deinit(); 343 | 344 | const data = self.allocator.dupe(u8, raw_obj.getData()) catch return GitError.ObjectReadFailed; 345 | return ObjectData{ 346 | .data = data, 347 | .object_type = raw_obj.getType(), 348 | .oid = oid_str, 349 | .allocator = self.allocator, 350 | }; 351 | } 352 | 353 | pub fn walkObjects(self: *Repository, start_oid: []const u8) GitError!ObjectIterator { 354 | return ObjectIterator.init(self.allocator, &self.raw, start_oid); 355 | } 356 | }; 357 | 358 | pub const ObjectData = struct { 359 | data: []const u8, 360 | object_type: []const u8, 361 | oid: []const u8, 362 | allocator: std.mem.Allocator, 363 | 364 | pub fn deinit(self: *ObjectData) void { 365 | self.allocator.free(self.data); 366 | } 367 | }; 368 | 369 | pub const ObjectWriter = struct { 370 | repo: *Repository, 371 | 372 | pub fn init(repo: *Repository) ObjectWriter { 373 | return ObjectWriter{ .repo = repo }; 374 | } 375 | 376 | pub fn writeObject(self: *ObjectWriter, obj_type: []const u8, data: []const u8) GitError![]const u8 { 377 | const oid = try writeObjectRaw(&self.repo.raw, obj_type, data); 378 | var oid_str: [41]u8 = undefined; 379 | oid.toString(&oid_str); 380 | return self.repo.allocator.dupe(u8, oid_str[0..40]) catch return GitError.ObjectReadFailed; 381 | } 382 | }; 383 | 384 | pub const Refspec = struct { 385 | raw: RawRefspec, 386 | allocator: std.mem.Allocator, 387 | 388 | pub fn parse(allocator: std.mem.Allocator, refspec_str: []const u8, is_fetch: bool) GitError!Refspec { 389 | assert(refspec_str.len > 0); 390 | const null_refspec = allocator.dupeZ(u8, refspec_str) catch return GitError.RefspecParseFailed; 391 | defer allocator.free(null_refspec); 392 | 393 | const raw = try RawRefspec.parse(null_refspec, is_fetch); 394 | return Refspec{ 395 | .raw = raw, 396 | .allocator = allocator, 397 | }; 398 | } 399 | 400 | pub fn deinit(self: *Refspec) void { 401 | self.raw.deinit(); 402 | } 403 | 404 | pub fn getSource(self: *const Refspec) []const u8 { 405 | return self.raw.getSource(); 406 | } 407 | 408 | pub fn getDestination(self: *const Refspec) []const u8 { 409 | return self.raw.getDestination(); 410 | } 411 | 412 | pub fn isForce(self: *const Refspec) bool { 413 | return self.raw.isForce(); 414 | } 415 | 416 | pub fn srcMatches(self: *const Refspec, ref_name: []const u8) bool { 417 | assert(ref_name.len > 0); 418 | const null_ref = self.allocator.dupeZ(u8, ref_name) catch return false; 419 | defer self.allocator.free(null_ref); 420 | return self.raw.srcMatches(null_ref); 421 | } 422 | 423 | pub fn dstMatches(self: *const Refspec, ref_name: []const u8) bool { 424 | assert(ref_name.len > 0); 425 | const null_ref = self.allocator.dupeZ(u8, ref_name) catch return false; 426 | defer self.allocator.free(null_ref); 427 | return self.raw.dstMatches(null_ref); 428 | } 429 | }; 430 | 431 | pub const ObjectIterator = struct { 432 | allocator: std.mem.Allocator, 433 | repo: *RawRepository, 434 | visited: std.StringHashMap(void), 435 | pending: std.ArrayList(PendingObject), 436 | revwalk: ?RawRevwalk, 437 | current_commit: ?RawOid, 438 | current_tree_stack: std.ArrayList(TreeContext), 439 | 440 | const PendingObject = struct { 441 | oid: RawOid, 442 | source: enum { commit, tree_root, tree_entry }, 443 | }; 444 | 445 | const TreeContext = struct { 446 | tree: RawTree, 447 | index: usize, 448 | }; 449 | 450 | pub fn init(allocator: std.mem.Allocator, repo: *RawRepository, start_oid: []const u8) GitError!ObjectIterator { 451 | assert(start_oid.len == 40); 452 | const oid = try RawOid.fromStr(start_oid); 453 | var revwalk = try RawRevwalk.new(repo); 454 | try revwalk.push(&oid); 455 | 456 | return ObjectIterator{ 457 | .allocator = allocator, 458 | .repo = repo, 459 | .visited = std.StringHashMap(void).init(allocator), 460 | .pending = std.ArrayList(PendingObject).init(allocator), 461 | .revwalk = revwalk, 462 | .current_commit = null, 463 | .current_tree_stack = std.ArrayList(TreeContext).init(allocator), 464 | }; 465 | } 466 | 467 | pub fn deinit(self: *ObjectIterator) void { 468 | var iterator = self.visited.iterator(); 469 | while (iterator.next()) |entry| { 470 | self.allocator.free(entry.key_ptr.*); 471 | } 472 | self.visited.deinit(); 473 | self.pending.deinit(); 474 | 475 | if (self.revwalk) |*rw| { 476 | rw.deinit(); 477 | } 478 | 479 | for (self.current_tree_stack.items) |*ctx| { 480 | ctx.tree.deinit(); 481 | } 482 | self.current_tree_stack.deinit(); 483 | } 484 | 485 | pub fn next(self: *ObjectIterator) GitError!?[]const u8 { 486 | while (true) { 487 | if (self.pending.items.len > 0) { 488 | const pending = self.pending.pop() orelse unreachable; 489 | var oid_str: [41]u8 = undefined; 490 | pending.oid.toString(&oid_str); 491 | 492 | if (try self.addIfNew(oid_str[0..40])) { 493 | switch (pending.source) { 494 | .commit => try self.queueCommitObjects(&pending.oid), 495 | .tree_root, .tree_entry => try self.queueTreeObjects(&pending.oid), 496 | } 497 | return self.allocator.dupe(u8, oid_str[0..40]) catch return GitError.RevwalkFailed; 498 | } 499 | continue; 500 | } 501 | 502 | if (self.current_tree_stack.items.len > 0) { 503 | if (try self.processCurrentTree()) { 504 | continue; 505 | } 506 | } 507 | 508 | if (self.revwalk) |*rw| { 509 | if (rw.next()) |commit_oid| { 510 | try self.pending.append(.{ .oid = commit_oid, .source = .commit }); 511 | continue; 512 | } 513 | } 514 | 515 | return null; 516 | } 517 | } 518 | 519 | fn addIfNew(self: *ObjectIterator, oid_str: []const u8) GitError!bool { 520 | if (self.visited.contains(oid_str)) { 521 | return false; 522 | } 523 | const owned_oid = self.allocator.dupe(u8, oid_str) catch return GitError.RevwalkFailed; 524 | self.visited.put(owned_oid, {}) catch { 525 | self.allocator.free(owned_oid); 526 | return GitError.RevwalkFailed; 527 | }; 528 | return true; 529 | } 530 | 531 | fn queueCommitObjects(self: *ObjectIterator, commit_oid: *const RawOid) GitError!void { 532 | var commit = RawCommit.lookup(self.repo, commit_oid) catch return; 533 | defer commit.deinit(); 534 | 535 | const tree = commit.getTree() catch return; 536 | const tree_oid = tree.getId(); 537 | try self.pending.append(.{ .oid = tree_oid, .source = .tree_root }); 538 | 539 | try self.current_tree_stack.append(.{ .tree = tree, .index = 0 }); 540 | } 541 | 542 | fn queueTreeObjects(self: *ObjectIterator, tree_oid: *const RawOid) GitError!void { 543 | const tree = RawTree.lookup(self.repo, tree_oid) catch return; 544 | try self.current_tree_stack.append(.{ .tree = tree, .index = 0 }); 545 | } 546 | 547 | fn processCurrentTree(self: *ObjectIterator) GitError!bool { 548 | while (self.current_tree_stack.items.len > 0) { 549 | const stack_top = &self.current_tree_stack.items[self.current_tree_stack.items.len - 1]; 550 | const tree = &stack_top.tree; 551 | 552 | if (stack_top.index >= tree.entryCount()) { 553 | var ctx = self.current_tree_stack.pop() orelse unreachable; 554 | ctx.tree.deinit(); 555 | continue; 556 | } 557 | 558 | if (tree.getEntryByIndex(stack_top.index)) |entry| { 559 | stack_top.index += 1; 560 | const entry_oid = entry.getId(); 561 | 562 | if (entry.isTree()) { 563 | try self.pending.append(.{ .oid = entry_oid, .source = .tree_entry }); 564 | } else { 565 | try self.pending.append(.{ .oid = entry_oid, .source = .tree_entry }); 566 | } 567 | return true; 568 | } 569 | stack_top.index += 1; 570 | } 571 | return false; 572 | } 573 | }; 574 | 575 | // --- 576 | 577 | const testing = std.testing; 578 | 579 | test "ObjectIterator deduplication" { 580 | const allocator = testing.allocator; 581 | 582 | var visited = std.StringHashMap(void).init(allocator); 583 | defer { 584 | var iterator = visited.iterator(); 585 | while (iterator.next()) |entry| { 586 | allocator.free(entry.key_ptr.*); 587 | } 588 | visited.deinit(); 589 | } 590 | 591 | var iter = ObjectIterator{ 592 | .allocator = allocator, 593 | .repo = undefined, 594 | .visited = visited, 595 | .pending = std.ArrayList(ObjectIterator.PendingObject).init(allocator), 596 | .revwalk = null, 597 | .current_commit = null, 598 | .current_tree_stack = std.ArrayList(ObjectIterator.TreeContext).init(allocator), 599 | }; 600 | defer { 601 | iter.pending.deinit(); 602 | iter.current_tree_stack.deinit(); 603 | } 604 | 605 | const test_oid = "1234567890abcdef1234567890abcdef12345678"; 606 | 607 | try testing.expect(try iter.addIfNew(test_oid)); 608 | try testing.expect(iter.visited.count() == 1); 609 | 610 | try testing.expect(!(try iter.addIfNew(test_oid))); 611 | try testing.expect(iter.visited.count() == 1); 612 | 613 | const test_oid2 = "abcdef1234567890abcdef1234567890abcdef12"; 614 | try testing.expect(try iter.addIfNew(test_oid2)); 615 | try testing.expect(iter.visited.count() == 2); 616 | 617 | iter.deinit(); 618 | } 619 | -------------------------------------------------------------------------------- /src/help.zig: -------------------------------------------------------------------------------- 1 | const std = @import("std"); 2 | const cmd = @import("cmd.zig"); 3 | 4 | // --- 5 | 6 | pub fn run(allocator: std.mem.Allocator, process: cmd.Process) !void { 7 | _ = allocator; 8 | _ = process.argv; 9 | 10 | try process.stdout.writeAll( 11 | \\Usage: git-remote-sqlite [options] 12 | \\ 13 | \\Commands: 14 | \\ config [key] [value] Configure repository settings 15 | \\ --list List all configurations 16 | \\ --get Get a specific configuration 17 | \\ --unset Remove a configuration 18 | \\ 19 | \\When symlinked as git-remote-sqlite, it functions as a Git remote helper. 20 | \\ 21 | ); 22 | } 23 | -------------------------------------------------------------------------------- /src/main.zig: -------------------------------------------------------------------------------- 1 | const std = @import("std"); 2 | const cli = @import("cli.zig"); 3 | const cmd = @import("cmd.zig"); 4 | 5 | // --- 6 | 7 | pub fn main() !void { 8 | var gpa = std.heap.GeneralPurposeAllocator(.{}){}; 9 | const allocator = gpa.allocator(); 10 | defer _ = gpa.deinit(); 11 | 12 | const args = try std.process.argsAlloc(allocator); 13 | defer std.process.argsFree(allocator, args); 14 | 15 | var env = try std.process.getEnvMap(allocator); 16 | defer env.deinit(); 17 | 18 | const process = cmd.Process{ 19 | .argv = args, 20 | .stdin = std.io.getStdIn().reader().any(), 21 | .stdout = std.io.getStdOut().writer().any(), 22 | .stderr = std.io.getStdErr().writer().any(), 23 | .env = env, 24 | }; 25 | 26 | try cli.run(allocator, process); 27 | } 28 | -------------------------------------------------------------------------------- /src/protocol.zig: -------------------------------------------------------------------------------- 1 | /// See gitremote-helpers(7) 2 | const std = @import("std"); 3 | const assert = std.debug.assert; 4 | const Allocator = std.mem.Allocator; 5 | 6 | // --- 7 | 8 | pub const ProtocolError = error{ 9 | InvalidCommand, 10 | UnexpectedEOF, 11 | MissingRefName, 12 | }; 13 | 14 | // --- 15 | 16 | pub const Command = union(enum) { 17 | capabilities, 18 | list: ?List, 19 | fetch: Fetch, 20 | push: Push, 21 | option: Option, 22 | import: Import, 23 | @"export": Export, 24 | connect: Connect, 25 | stateless_connect: StatelessConnect, 26 | get: Get, 27 | 28 | pub const List = enum { 29 | for_push, 30 | 31 | pub fn format(self: List, comptime fmt: []const u8, options: std.fmt.FormatOptions, writer: anytype) !void { 32 | _ = fmt; 33 | _ = options; 34 | switch (self) { 35 | .for_push => try writer.writeAll("for_push"), 36 | } 37 | } 38 | }; 39 | 40 | pub const Fetch = struct { 41 | sha1: []const u8, 42 | name: []const u8, 43 | 44 | pub fn format(self: Fetch, comptime fmt: []const u8, options: std.fmt.FormatOptions, writer: anytype) !void { 45 | _ = fmt; 46 | _ = options; 47 | try writer.print("fetch {s} {s}", .{ self.sha1, self.name }); 48 | } 49 | }; 50 | 51 | pub const Push = struct { 52 | refspec: []const u8, 53 | 54 | pub fn format(self: Push, comptime fmt: []const u8, options: std.fmt.FormatOptions, writer: anytype) !void { 55 | _ = fmt; 56 | _ = options; 57 | try writer.print("push {s}", .{self.refspec}); 58 | } 59 | }; 60 | 61 | pub const Option = struct { 62 | name: []const u8, 63 | value: []const u8, 64 | 65 | pub fn format(self: Option, comptime fmt: []const u8, options: std.fmt.FormatOptions, writer: anytype) !void { 66 | _ = fmt; 67 | _ = options; 68 | try writer.print("option {s} {s}", .{ self.name, self.value }); 69 | } 70 | }; 71 | 72 | pub const Import = struct { 73 | name: []const u8, 74 | 75 | pub fn format(self: Import, comptime fmt: []const u8, options: std.fmt.FormatOptions, writer: anytype) !void { 76 | _ = fmt; 77 | _ = options; 78 | try writer.print("import {s}", .{self.name}); 79 | } 80 | }; 81 | 82 | pub const Export = struct { 83 | pub fn format(self: Export, comptime fmt: []const u8, options: std.fmt.FormatOptions, writer: anytype) !void { 84 | _ = self; 85 | _ = fmt; 86 | _ = options; 87 | try writer.writeAll("export"); 88 | } 89 | }; 90 | 91 | pub const Connect = struct { 92 | service: []const u8, 93 | 94 | pub fn format(self: Connect, comptime fmt: []const u8, options: std.fmt.FormatOptions, writer: anytype) !void { 95 | _ = fmt; 96 | _ = options; 97 | try writer.print("connect {s}", .{self.service}); 98 | } 99 | }; 100 | 101 | pub const StatelessConnect = struct { 102 | service: []const u8, 103 | 104 | pub fn format(self: StatelessConnect, comptime fmt: []const u8, options: std.fmt.FormatOptions, writer: anytype) !void { 105 | _ = fmt; 106 | _ = options; 107 | try writer.print("stateless-connect {s}", .{self.service}); 108 | } 109 | }; 110 | 111 | pub const Get = struct { 112 | uri: []const u8, 113 | path: []const u8, 114 | 115 | pub fn format(self: Get, comptime fmt: []const u8, options: std.fmt.FormatOptions, writer: anytype) !void { 116 | _ = fmt; 117 | _ = options; 118 | try writer.print("get {s} {s}", .{ self.uri, self.path }); 119 | } 120 | }; 121 | 122 | pub fn format(self: Command, comptime fmt: []const u8, options: std.fmt.FormatOptions, writer: anytype) !void { 123 | _ = fmt; 124 | _ = options; 125 | switch (self) { 126 | .capabilities => try writer.writeAll("capabilities"), 127 | .list => |list| { 128 | try writer.writeAll("list"); 129 | if (list) |l| { 130 | try writer.writeAll(" "); 131 | try l.format("", .{}, writer); 132 | } 133 | }, 134 | .fetch => |fetch| try fetch.format("", .{}, writer), 135 | .push => |push| try push.format("", .{}, writer), 136 | .option => |option| try option.format("", .{}, writer), 137 | .import => |import| try import.format("", .{}, writer), 138 | .@"export" => |export_cmd| try export_cmd.format("", .{}, writer), 139 | .connect => |connect| try connect.format("", .{}, writer), 140 | .stateless_connect => |stateless_connect| try stateless_connect.format("", .{}, writer), 141 | .get => |get| try get.format("", .{}, writer), 142 | } 143 | } 144 | }; 145 | 146 | // --- 147 | 148 | pub const Ref = struct { 149 | sha1: []const u8, 150 | name: []const u8, 151 | symref_target: ?[]const u8, 152 | attributes: []const []const u8 = &[_][]const u8{}, 153 | keywords: []const Keyword = &[_]Keyword{}, 154 | 155 | pub const Keyword = struct { 156 | key: []const u8, 157 | value: []const u8, 158 | }; 159 | 160 | pub fn format(self: @This(), comptime fmt: []const u8, options: std.fmt.FormatOptions, writer: anytype) !void { 161 | _ = fmt; 162 | _ = options; 163 | 164 | // Write value (sha1, symref, or keyword) 165 | if (self.keywords.len > 0) { 166 | for (self.keywords, 0..) |keyword, i| { 167 | if (i > 0) try writer.writeAll(" "); 168 | try writer.print(":{s} {s}", .{ keyword.key, keyword.value }); 169 | } 170 | } else if (self.symref_target) |target| { 171 | try writer.writeAll("@"); 172 | try writer.writeAll(target); 173 | } else if (self.sha1.len == 1 and self.sha1[0] == '?') { 174 | try writer.writeAll("?"); 175 | } else { 176 | try writer.writeAll(self.sha1); 177 | } 178 | 179 | try writer.writeAll(" "); 180 | try writer.writeAll(self.name); 181 | 182 | // Write attributes 183 | for (self.attributes) |attr| { 184 | try writer.writeAll(" "); 185 | try writer.writeAll(attr); 186 | } 187 | } 188 | }; 189 | 190 | pub const Response = union(enum) { 191 | capabilities: Capabilities, 192 | list: List, 193 | option: Option, 194 | fetch: Fetch, 195 | push: Push, 196 | connect: Connect, 197 | 198 | pub fn format(self: Response, comptime fmt: []const u8, options: std.fmt.FormatOptions, writer: anytype) !void { 199 | _ = fmt; 200 | _ = options; 201 | switch (self) { 202 | .capabilities => |caps| try caps.format("", .{}, writer), 203 | .list => |list| try list.format("", .{}, writer), 204 | .option => |opt| try opt.format("", .{}, writer), 205 | .fetch => |fetch| try fetch.format("", .{}, writer), 206 | .push => |push| try push.format("", .{}, writer), 207 | .connect => |connect| try connect.format("", .{}, writer), 208 | } 209 | } 210 | 211 | pub const List = struct { 212 | refs: []const Ref, 213 | 214 | pub fn format(self: List, comptime fmt: []const u8, options: std.fmt.FormatOptions, writer: anytype) !void { 215 | _ = fmt; 216 | _ = options; 217 | for (self.refs) |ref| { 218 | try ref.format("", .{}, writer); 219 | try writer.writeAll("\n"); 220 | } 221 | try writer.writeAll("\n"); 222 | } 223 | }; 224 | 225 | pub const Option = union(enum) { 226 | ok, 227 | unsupported, 228 | @"error": []const u8, 229 | 230 | pub fn format(self: Option, comptime fmt: []const u8, options: std.fmt.FormatOptions, writer: anytype) !void { 231 | _ = fmt; 232 | _ = options; 233 | switch (self) { 234 | .ok => try writer.writeAll("ok\n"), 235 | .unsupported => try writer.writeAll("unsupported\n"), 236 | .@"error" => |msg| { 237 | try writer.writeAll("error "); 238 | try writer.writeAll(msg); 239 | try writer.writeAll("\n"); 240 | }, 241 | } 242 | } 243 | }; 244 | 245 | pub const Fetch = union(enum) { 246 | complete, 247 | lock: []const u8, 248 | connectivity_ok, 249 | 250 | pub fn format(self: Fetch, comptime fmt: []const u8, options: std.fmt.FormatOptions, writer: anytype) !void { 251 | _ = fmt; 252 | _ = options; 253 | switch (self) { 254 | .complete => try writer.writeAll("\n"), 255 | .lock => |path| { 256 | try writer.writeAll("lock "); 257 | try writer.writeAll(path); 258 | try writer.writeAll("\n"); 259 | }, 260 | .connectivity_ok => try writer.writeAll("connectivity-ok\n"), 261 | } 262 | } 263 | }; 264 | 265 | pub const Push = struct { 266 | results: []const PushResult, 267 | 268 | pub const PushResult = union(enum) { 269 | ok: []const u8, 270 | @"error": PushError, 271 | }; 272 | 273 | pub const PushError = struct { 274 | dst: []const u8, 275 | why: ?[]const u8, 276 | }; 277 | 278 | pub fn format(self: Push, comptime fmt: []const u8, options: std.fmt.FormatOptions, writer: anytype) !void { 279 | _ = fmt; 280 | _ = options; 281 | for (self.results) |result| { 282 | switch (result) { 283 | .ok => |dst| { 284 | try writer.writeAll("ok "); 285 | try writer.writeAll(dst); 286 | try writer.writeAll("\n"); 287 | }, 288 | .@"error" => |err| { 289 | try writer.writeAll("error "); 290 | try writer.writeAll(err.dst); 291 | if (err.why) |why| { 292 | try writer.writeAll(" "); 293 | try writer.writeAll(why); 294 | } 295 | try writer.writeAll("\n"); 296 | }, 297 | } 298 | } 299 | try writer.writeAll("\n"); 300 | } 301 | }; 302 | 303 | pub const Connect = union(enum) { 304 | established, 305 | fallback, 306 | 307 | pub fn format(self: Connect, comptime fmt: []const u8, options: std.fmt.FormatOptions, writer: anytype) !void { 308 | _ = fmt; 309 | _ = options; 310 | switch (self) { 311 | .established => try writer.writeAll("\n"), 312 | .fallback => try writer.writeAll("fallback\n"), 313 | } 314 | } 315 | }; 316 | 317 | pub const Capabilities = struct { 318 | import: bool = false, 319 | @"export": bool = false, 320 | push: bool = false, 321 | fetch: bool = false, 322 | connect: bool = false, 323 | stateless_connect: bool = false, 324 | check_connectivity: bool = false, 325 | get: bool = false, 326 | bidi_import: bool = false, 327 | signed_tags: bool = false, 328 | object_format: bool = false, 329 | no_private_update: bool = false, 330 | progress: bool = false, 331 | option: bool = false, 332 | refspec: ?[]const u8 = null, 333 | export_marks: ?[]const u8 = null, 334 | import_marks: ?[]const u8 = null, 335 | 336 | pub fn format(self: Capabilities, comptime fmt: []const u8, options: std.fmt.FormatOptions, writer: anytype) !void { 337 | _ = fmt; 338 | _ = options; 339 | try writer.writeAll("capabilities\n"); 340 | if (self.import) try writer.writeAll("import\n"); 341 | if (self.@"export") try writer.writeAll("export\n"); 342 | if (self.push) try writer.writeAll("push\n"); 343 | if (self.fetch) try writer.writeAll("fetch\n"); 344 | if (self.connect) try writer.writeAll("connect\n"); 345 | if (self.stateless_connect) try writer.writeAll("stateless-connect\n"); 346 | if (self.check_connectivity) try writer.writeAll("check-connectivity\n"); 347 | if (self.get) try writer.writeAll("get\n"); 348 | if (self.bidi_import) try writer.writeAll("bidi-import\n"); 349 | if (self.signed_tags) try writer.writeAll("signed-tags\n"); 350 | if (self.object_format) try writer.writeAll("object-format\n"); 351 | if (self.no_private_update) try writer.writeAll("no-private-update\n"); 352 | if (self.progress) try writer.writeAll("progress\n"); 353 | if (self.option) try writer.writeAll("option\n"); 354 | if (self.refspec) |refspec| { 355 | try writer.writeAll("refspec "); 356 | try writer.writeAll(refspec); 357 | try writer.writeAll("\n"); 358 | } 359 | if (self.export_marks) |file| { 360 | try writer.writeAll("export-marks "); 361 | try writer.writeAll(file); 362 | try writer.writeAll("\n"); 363 | } 364 | if (self.import_marks) |file| { 365 | try writer.writeAll("import-marks "); 366 | try writer.writeAll(file); 367 | try writer.writeAll("\n"); 368 | } 369 | try writer.writeAll("\n"); 370 | } 371 | }; 372 | }; 373 | 374 | // --- 375 | 376 | /// Read and parse a command from the input stream. 377 | pub fn readCommand(allocator: Allocator, reader: std.io.AnyReader) !?Command { 378 | var buffer: [4096]u8 = undefined; 379 | 380 | while (true) { 381 | const line = try reader.readUntilDelimiterOrEof(buffer[0..], '\n'); 382 | const some_line = line orelse return null; // EOF 383 | const trimmed = std.mem.trim(u8, some_line, &std.ascii.whitespace); 384 | if (trimmed.len == 0) continue; // Skip empty lines 385 | 386 | return try parseCommand(allocator, trimmed); 387 | } 388 | } 389 | 390 | fn parseCommand(allocator: Allocator, line: []const u8) !?Command { 391 | const trimmed = std.mem.trim(u8, line, &std.ascii.whitespace); 392 | if (trimmed.len == 0) return null; 393 | 394 | var tokens = std.mem.tokenizeScalar(u8, trimmed, ' '); 395 | const cmd = tokens.next() orelse return null; 396 | 397 | if (std.mem.eql(u8, cmd, "capabilities")) { 398 | return .capabilities; 399 | } else if (std.mem.eql(u8, cmd, "list")) { 400 | const arg = tokens.next(); 401 | const list_type: ?Command.List = if (arg) |a| 402 | if (std.mem.eql(u8, a, "for-push")) .for_push else null 403 | else 404 | null; 405 | return .{ .list = list_type }; 406 | } else if (std.mem.eql(u8, cmd, "fetch")) { 407 | const sha1 = tokens.next() orelse return ProtocolError.InvalidCommand; 408 | const name = tokens.next() orelse return ProtocolError.InvalidCommand; 409 | assert(sha1.len > 0); 410 | assert(name.len > 0); 411 | return .{ .fetch = .{ 412 | .sha1 = try allocator.dupe(u8, sha1), 413 | .name = try allocator.dupe(u8, name), 414 | } }; 415 | } else if (std.mem.eql(u8, cmd, "push")) { 416 | const refspec = tokens.next() orelse return ProtocolError.InvalidCommand; 417 | assert(refspec.len > 0); 418 | 419 | return .{ .push = .{ 420 | .refspec = try allocator.dupe(u8, refspec), 421 | } }; 422 | } else if (std.mem.eql(u8, cmd, "option")) { 423 | const name = tokens.next() orelse return ProtocolError.InvalidCommand; 424 | const value = tokens.next() orelse return ProtocolError.InvalidCommand; 425 | assert(name.len > 0); 426 | assert(value.len > 0); 427 | 428 | return .{ .option = .{ 429 | .name = try allocator.dupe(u8, name), 430 | .value = try allocator.dupe(u8, value), 431 | } }; 432 | } else if (std.mem.eql(u8, cmd, "import")) { 433 | const name = tokens.next() orelse return ProtocolError.InvalidCommand; 434 | assert(name.len > 0); 435 | return .{ .import = .{ 436 | .name = try allocator.dupe(u8, name), 437 | } }; 438 | } else if (std.mem.eql(u8, cmd, "export")) { 439 | return .{ .@"export" = .{} }; 440 | } else if (std.mem.eql(u8, cmd, "connect")) { 441 | const service = tokens.next() orelse return ProtocolError.InvalidCommand; 442 | assert(service.len > 0); 443 | return .{ .connect = .{ 444 | .service = try allocator.dupe(u8, service), 445 | } }; 446 | } else if (std.mem.eql(u8, cmd, "stateless-connect")) { 447 | const service = tokens.next() orelse return ProtocolError.InvalidCommand; 448 | assert(service.len > 0); 449 | return .{ .stateless_connect = .{ 450 | .service = try allocator.dupe(u8, service), 451 | } }; 452 | } else if (std.mem.eql(u8, cmd, "get")) { 453 | const uri = tokens.next() orelse return ProtocolError.InvalidCommand; 454 | const path = tokens.next() orelse return ProtocolError.InvalidCommand; 455 | assert(uri.len > 0); 456 | assert(path.len > 0); 457 | return .{ .get = .{ 458 | .uri = try allocator.dupe(u8, uri), 459 | .path = try allocator.dupe(u8, path), 460 | } }; 461 | } 462 | 463 | // Unrecognized command (expected for unsupported protocol features) 464 | return ProtocolError.InvalidCommand; 465 | } 466 | 467 | // --- 468 | 469 | const TestReader = struct { 470 | data: []const u8, 471 | pos: usize = 0, 472 | 473 | fn init(data: []const u8) TestReader { 474 | return TestReader{ .data = data }; 475 | } 476 | 477 | fn reader(self: *TestReader) std.io.AnyReader { 478 | return .{ 479 | .context = @ptrCast(self), 480 | .readFn = readFn, 481 | }; 482 | } 483 | 484 | fn readFn(context: *const anyopaque, buffer: []u8) anyerror!usize { 485 | const self: *TestReader = @ptrCast(@alignCast(@constCast(context))); 486 | if (self.pos >= self.data.len) return 0; 487 | const available = self.data.len - self.pos; 488 | const to_read = @min(buffer.len, available); 489 | @memcpy(buffer[0..to_read], self.data[self.pos .. self.pos + to_read]); 490 | self.pos += to_read; 491 | return to_read; 492 | } 493 | }; 494 | 495 | const testing = std.testing; 496 | 497 | test "readCommand parses git protocol commands" { 498 | var arena = std.heap.ArenaAllocator.init(testing.allocator); 499 | defer arena.deinit(); 500 | const allocator = arena.allocator(); 501 | 502 | var input_data = TestReader.init("capabilities\nlist\nfetch abc123 refs/heads/main\n"); 503 | 504 | const cmd1 = try readCommand(allocator, input_data.reader()); 505 | try testing.expectEqual(Command.capabilities, cmd1.?); 506 | 507 | const cmd2 = try readCommand(allocator, input_data.reader()); 508 | try testing.expectEqual(@as(?Command.List, null), cmd2.?.list); 509 | 510 | const cmd3 = try readCommand(allocator, input_data.reader()); 511 | try testing.expectEqualStrings("abc123", cmd3.?.fetch.sha1); 512 | try testing.expectEqualStrings("refs/heads/main", cmd3.?.fetch.name); 513 | } 514 | 515 | test "readCommand handles EOF" { 516 | const allocator = testing.allocator; 517 | var input_data = TestReader.init(""); 518 | 519 | const cmd = try readCommand(allocator, input_data.reader()); 520 | try testing.expect(cmd == null); 521 | } 522 | 523 | test "readCommand skips empty lines" { 524 | const allocator = testing.allocator; 525 | var input_data = TestReader.init("\n \n\ncapabilities\n"); 526 | 527 | const cmd1 = try readCommand(allocator, input_data.reader()); 528 | try testing.expectEqual(Command.capabilities, cmd1.?); 529 | 530 | const cmd2 = try readCommand(allocator, input_data.reader()); 531 | try testing.expect(cmd2 == null); 532 | } 533 | 534 | test "parseCommand handles all git protocol commands" { 535 | const allocator = testing.allocator; 536 | 537 | const cmd = try parseCommand(allocator, "capabilities"); 538 | try testing.expectEqual(Command.capabilities, cmd.?); 539 | } 540 | 541 | test "parseCommand rejects invalid commands" { 542 | const allocator = testing.allocator; 543 | 544 | try testing.expectError(ProtocolError.InvalidCommand, parseCommand(allocator, "fetch")); 545 | try testing.expectError(ProtocolError.InvalidCommand, parseCommand(allocator, "invalid")); 546 | } 547 | 548 | test "command formatting follows git protocol" { 549 | const allocator = testing.allocator; 550 | var output = std.ArrayList(u8).init(allocator); 551 | defer output.deinit(); 552 | 553 | const cmd1 = Command{ .connect = .{ .service = "git-upload-pack" } }; 554 | try cmd1.format("", .{}, output.writer().any()); 555 | try testing.expectEqualStrings("connect git-upload-pack", output.items); 556 | 557 | output.clearRetainingCapacity(); 558 | const cmd2 = Command{ .list = .for_push }; 559 | try cmd2.format("", .{}, output.writer().any()); 560 | try testing.expectEqualStrings("list for_push", output.items); 561 | } 562 | 563 | test "capabilities response follows git protocol" { 564 | const allocator = testing.allocator; 565 | var output = std.ArrayList(u8).init(allocator); 566 | defer output.deinit(); 567 | 568 | const response = Response{ .capabilities = .{ 569 | .import = true, 570 | .@"export" = true, 571 | .push = true, 572 | .fetch = true, 573 | .option = true, 574 | .refspec = "refs/heads/*:refs/remotes/origin/*", 575 | } }; 576 | try response.format("", .{}, output.writer().any()); 577 | 578 | const expected = "capabilities\nimport\nexport\npush\nfetch\noption\nrefspec refs/heads/*:refs/remotes/origin/*\n\n"; 579 | try testing.expectEqualStrings(expected, output.items); 580 | } 581 | 582 | test "list response follows git protocol format" { 583 | const allocator = testing.allocator; 584 | var output = std.ArrayList(u8).init(allocator); 585 | defer output.deinit(); 586 | 587 | const refs = [_]Ref{ 588 | .{ .name = "refs/heads/main", .sha1 = "abc123", .symref_target = null }, 589 | .{ .name = "HEAD", .sha1 = "", .symref_target = "refs/heads/main" }, 590 | }; 591 | 592 | const response = Response{ .list = .{ .refs = &refs } }; 593 | try response.format("", .{}, output.writer().any()); 594 | const expected = "abc123 refs/heads/main\n@refs/heads/main HEAD\n\n"; 595 | try testing.expectEqualStrings(expected, output.items); 596 | } 597 | 598 | test "ref list supports git protocol features" { 599 | const allocator = testing.allocator; 600 | var output = std.ArrayList(u8).init(allocator); 601 | defer output.deinit(); 602 | 603 | const attrs = [_][]const u8{"unchanged"}; 604 | const keywords = [_]Ref.Keyword{.{ .key = "object-format", .value = "sha256" }}; 605 | const refs = [_]Ref{ 606 | .{ .name = "refs/heads/main", .sha1 = "abc123", .symref_target = null, .attributes = &attrs }, 607 | .{ .name = "refs/heads/dev", .sha1 = "", .symref_target = null, .keywords = &keywords }, 608 | .{ .name = "refs/heads/unknown", .sha1 = "?", .symref_target = null }, 609 | }; 610 | 611 | const response = Response{ .list = .{ .refs = &refs } }; 612 | try response.format("", .{}, output.writer().any()); 613 | const expected = "abc123 refs/heads/main unchanged\n:object-format sha256 refs/heads/dev\n? refs/heads/unknown\n\n"; 614 | try testing.expectEqualStrings(expected, output.items); 615 | } 616 | 617 | test "arena allocator handles command memory" { 618 | var arena = std.heap.ArenaAllocator.init(testing.allocator); 619 | defer arena.deinit(); 620 | const allocator = arena.allocator(); 621 | 622 | const cmd = try parseCommand(allocator, "fetch abc123 refs/heads/main"); 623 | try testing.expectEqualStrings("abc123", cmd.?.fetch.sha1); 624 | try testing.expectEqualStrings("refs/heads/main", cmd.?.fetch.name); 625 | } 626 | -------------------------------------------------------------------------------- /src/remote.zig: -------------------------------------------------------------------------------- 1 | const std = @import("std"); 2 | const sqlite = @import("sqlite.zig"); 3 | const protocol = @import("protocol.zig"); 4 | const git = @import("git.zig"); 5 | const transport = @import("transport.zig"); 6 | const cmd = @import("cmd.zig"); 7 | 8 | // --- 9 | 10 | pub const Remote = struct { 11 | pub const Error = error{ 12 | DatabaseError, 13 | InvalidArgs, 14 | GitDirNotSet, 15 | } || git.GitError || transport.RemoteUrlError; 16 | 17 | db: *sqlite.Database, 18 | objects: sqlite.ObjectDatabase, 19 | refs: sqlite.RefDatabase, 20 | allocator: std.mem.Allocator, 21 | repo: *git.Repository, 22 | 23 | /// Create new SQLite remote instance 24 | pub fn init(allocator: std.mem.Allocator, db: *sqlite.Database, repo: *git.Repository) Error!Remote { 25 | 26 | var remote = Remote{ 27 | .db = db, 28 | .objects = undefined, 29 | .refs = undefined, 30 | .allocator = allocator, 31 | .repo = repo, 32 | }; 33 | 34 | // Initialize database components with automatic schema migration 35 | remote.objects = sqlite.ObjectDatabase.init(remote.db) catch return Error.DatabaseError; 36 | remote.refs = sqlite.RefDatabase.init(remote.db) catch return Error.DatabaseError; 37 | 38 | return remote; 39 | } 40 | 41 | pub fn capabilities(_: *Remote, _: std.mem.Allocator) !protocol.Response.Capabilities { 42 | return .{ 43 | .import = false, 44 | .@"export" = false, 45 | .push = true, 46 | .fetch = true, 47 | .connect = false, 48 | .stateless_connect = false, 49 | .check_connectivity = false, 50 | .get = false, 51 | .bidi_import = false, 52 | .signed_tags = false, 53 | .object_format = false, 54 | .no_private_update = false, 55 | .progress = true, 56 | .option = true, 57 | .refspec = null, 58 | }; 59 | } 60 | 61 | pub fn list(self: *Remote, allocator: std.mem.Allocator, for_push: ?protocol.Command.List) !protocol.Response.List { 62 | // TODO: Handle for_push differently - might want to show different refs for push vs fetch 63 | _ = for_push; 64 | 65 | // Check if the git_refs table exists, if not return empty list 66 | const table_count_str = self.db.oneText(allocator, "SELECT COUNT(*) FROM sqlite_master WHERE type='table' AND name='git_refs'", &[_][]const u8{}) catch { 67 | // If we can't even query sqlite_master, return empty list 68 | return .{ .refs = try allocator.alloc(protocol.Ref, 0) }; 69 | }; 70 | defer if (table_count_str) |str| allocator.free(str); 71 | 72 | if (table_count_str == null) { 73 | return .{ .refs = try allocator.alloc(protocol.Ref, 0) }; 74 | } 75 | 76 | const table_count = std.fmt.parseInt(i32, table_count_str.?, 10) catch 0; 77 | if (table_count == 0) { 78 | // Table doesn't exist, return empty list (database not initialized) 79 | return .{ .refs = try allocator.alloc(protocol.Ref, 0) }; 80 | } 81 | 82 | const ref_data = try self.refs.iterateRefs(allocator); 83 | defer { 84 | for (ref_data) |ref| { 85 | ref.deinit(allocator); 86 | } 87 | allocator.free(ref_data); 88 | } 89 | 90 | var refs = std.ArrayList(protocol.Ref).init(allocator); 91 | 92 | // For empty repositories, don't advertise HEAD - let Git handle default branch 93 | if (ref_data.len == 0) { 94 | // Return empty list for empty repositories 95 | } else { 96 | for (ref_data) |ref| { 97 | try refs.append(.{ 98 | .name = try allocator.dupe(u8, ref.name), 99 | .sha1 = try allocator.dupe(u8, ref.sha), 100 | .symref_target = null, 101 | }); 102 | } 103 | } 104 | 105 | return .{ .refs = try refs.toOwnedSlice() }; 106 | } 107 | 108 | pub fn fetch(self: *Remote, allocator: std.mem.Allocator, fetch_cmd: protocol.Command.Fetch) Error!protocol.Response.Fetch { 109 | // TODO: Use fetch_cmd.sha1 and fetch_cmd.name for selective fetching instead of all objects 110 | _ = fetch_cmd; 111 | 112 | // Use the injected repository for fetch operations 113 | const repo = self.repo; 114 | 115 | var object_writer = git.ObjectWriter.init(repo); 116 | 117 | // Database transaction management 118 | self.db.exec("BEGIN TRANSACTION") catch return Error.DatabaseError; 119 | errdefer _ = self.db.exec("ROLLBACK") catch {}; 120 | 121 | // Get all object types and transfer them 122 | const object_types = [_]sqlite.ObjectType{ .blob, .tree, .commit, .tag }; 123 | 124 | for (object_types) |obj_type| { 125 | const shas = self.objects.iterateObjectsByType(allocator, obj_type) catch return Error.DatabaseError; 126 | defer { 127 | for (shas) |sha| { 128 | allocator.free(sha); 129 | } 130 | allocator.free(shas); 131 | } 132 | 133 | for (shas) |sha| { 134 | if (self.objects.readObject(allocator, sha) catch null) |object_data| { 135 | defer object_data.deinit(allocator); 136 | 137 | // Object writing and verification logic 138 | const written_sha = try object_writer.writeObject(object_data.object_type.toString(), object_data.data); 139 | defer repo.allocator.free(written_sha); 140 | 141 | // Verify the SHA matches 142 | if (!std.mem.eql(u8, sha, written_sha)) { 143 | return Error.DatabaseError; 144 | } 145 | } 146 | } 147 | } 148 | 149 | self.db.exec("COMMIT") catch return Error.DatabaseError; 150 | 151 | return .complete; 152 | } 153 | 154 | pub fn push(self: *Remote, allocator: std.mem.Allocator, push_cmd: protocol.Command.Push) !protocol.Response.Push { 155 | const refspec = push_cmd.refspec; 156 | 157 | // Parse refspec to get destination for response 158 | var parsed_refspec = git.Refspec.parse(allocator, refspec, false) catch |err| { 159 | const error_message = switch (err) { 160 | error.RefspecParseFailed => "Invalid refspec format", 161 | else => "Failed to parse refspec", 162 | }; 163 | return protocol.Response.Push{ 164 | .results = try allocator.dupe(protocol.Response.Push.PushResult, &[_]protocol.Response.Push.PushResult{ 165 | .{ .@"error" = .{ .dst = try allocator.dupe(u8, refspec), .why = try allocator.dupe(u8, error_message) } }, 166 | }), 167 | }; 168 | }; 169 | defer parsed_refspec.deinit(); 170 | 171 | const src = parsed_refspec.getSource(); 172 | const dst = parsed_refspec.getDestination(); 173 | 174 | // Only copy dst since it's returned in the response and must outlive the refspec 175 | const dst_owned = try allocator.dupe(u8, dst); 176 | 177 | // Use the injected repository for push operations 178 | const repo = self.repo; 179 | 180 | // Database transaction management 181 | self.db.exec("BEGIN TRANSACTION") catch return Error.DatabaseError; 182 | errdefer _ = self.db.exec("ROLLBACK") catch {}; 183 | 184 | // Resolve reference to get the commit SHA 185 | const sha = repo.resolveRef(src) catch { 186 | _ = self.db.exec("ROLLBACK") catch {}; 187 | return protocol.Response.Push{ 188 | .results = try allocator.dupe(protocol.Response.Push.PushResult, &[_]protocol.Response.Push.PushResult{ 189 | .{ .@"error" = .{ .dst = dst_owned, .why = try allocator.dupe(u8, "Failed to resolve reference") } }, 190 | }), 191 | }; 192 | }; 193 | defer repo.allocator.free(sha); 194 | 195 | // Walk all objects reachable from the commit 196 | var object_iter = try repo.walkObjects(sha); 197 | defer object_iter.deinit(); 198 | 199 | // Store all reachable objects 200 | while (object_iter.next() catch null) |obj_sha| { 201 | defer repo.allocator.free(obj_sha); 202 | try self.storeObject(allocator, repo, obj_sha); 203 | } 204 | 205 | // Store ref in database 206 | try self.refs.writeRef(dst_owned, sha, "branch"); 207 | self.db.exec("COMMIT") catch return Error.DatabaseError; 208 | 209 | const results = try allocator.alloc(protocol.Response.Push.PushResult, 1); 210 | results[0] = .{ .ok = dst_owned }; 211 | return .{ .results = results }; 212 | } 213 | 214 | fn storeObject(self: *Remote, allocator: std.mem.Allocator, repo: *git.Repository, sha: []const u8) Error!void { 215 | _ = allocator; // TODO: May need for error message formatting in the future 216 | 217 | // Check if object already exists to avoid redundant work 218 | if (self.objects.hasObject(sha) catch false) { 219 | return; // Object already stored 220 | } 221 | 222 | // Get object data from repository 223 | var object_data = try repo.getObjectData(sha); 224 | defer object_data.deinit(); 225 | 226 | // Store in database 227 | const parsed_type = sqlite.ObjectType.fromString(object_data.object_type) orelse { 228 | return Error.DatabaseError; 229 | }; 230 | self.objects.writeObject(sha, parsed_type, object_data.data) catch return Error.DatabaseError; 231 | } 232 | }; 233 | 234 | // --- 235 | 236 | /// Entry point for git-remote-sqlite - handles Git remote helper protocol 237 | pub fn run(allocator: std.mem.Allocator, process: cmd.Process) !void { 238 | if (process.argv.len < 2) { 239 | return Remote.Error.InvalidArgs; 240 | } 241 | 242 | // Git calls: git-remote-sqlite 243 | // Skip the remote name (process.argv[0]) and use the URL (process.argv[1]) 244 | const parsed_url = transport.parseUrl(allocator, process.argv[1]) catch return transport.RemoteUrlError.UnsupportedProtocol; 245 | 246 | if (!std.mem.eql(u8, parsed_url.protocol, "sqlite")) { 247 | return transport.RemoteUrlError.UnsupportedProtocol; 248 | } 249 | 250 | // Convert path to null-terminated for SQLite 251 | const null_terminated_path = try allocator.dupeZ(u8, parsed_url.path); 252 | defer allocator.free(null_terminated_path); 253 | 254 | var db = try sqlite.Database.open(allocator, null_terminated_path); 255 | defer db.close(); 256 | 257 | const git_dir = process.env.get("GIT_DIR") orelse return Remote.Error.GitDirNotSet; 258 | git.init(); 259 | var repo = try git.Repository.open(allocator, git_dir); 260 | defer repo.deinit(); 261 | var remote = try Remote.init(allocator, &db, &repo); 262 | 263 | var protocol_handler = transport.ProtocolHandler.init(allocator, process.stdin, process.stdout, process.stderr); 264 | defer protocol_handler.deinit(); 265 | 266 | try protocol_handler.run(&remote); 267 | } 268 | 269 | // Tests 270 | 271 | // --- 272 | 273 | const testing = std.testing; 274 | 275 | // Helper function to create a temporary git repository for tests 276 | fn createTestRepo(allocator: std.mem.Allocator) !git.Repository { 277 | git.init(); 278 | 279 | // Try to use current directory first (works when running from project root) 280 | if (git.Repository.open(allocator, ".")) |repo| { 281 | return repo; 282 | } else |_| { 283 | // TODO: Create a temporary git repository for tests when the working directory isn't a git repo 284 | // For now, fail the test since a repository is a necessary condition 285 | return git.GitError.RepoOpenFailed; 286 | } 287 | } 288 | 289 | test "capabilities" { 290 | const allocator = testing.allocator; 291 | 292 | var db = try sqlite.Database.open(allocator, ":memory:"); 293 | defer db.close(); 294 | 295 | // Create a test repository 296 | var repo = try createTestRepo(allocator); 297 | defer repo.deinit(); 298 | 299 | var remote = try Remote.init(allocator, &db, &repo); 300 | 301 | const response = try remote.capabilities(allocator); 302 | const caps = response; 303 | try testing.expect(caps.import == false); 304 | try testing.expect(caps.@"export" == false); 305 | try testing.expect(caps.push == true); 306 | try testing.expect(caps.fetch == true); 307 | try testing.expect(caps.progress == true); 308 | try testing.expect(caps.option == true); 309 | try testing.expect(caps.refspec == null); 310 | } 311 | 312 | test "libgit2 refspec parsing - with colon separator" { 313 | const allocator = testing.allocator; 314 | var refspec = try git.Refspec.parse(allocator, "refs/heads/main:refs/heads/main", false); 315 | defer refspec.deinit(); 316 | 317 | try testing.expectEqualStrings("refs/heads/main", refspec.getSource()); 318 | try testing.expectEqualStrings("refs/heads/main", refspec.getDestination()); 319 | } 320 | 321 | test "libgit2 refspec parsing - without colon separator" { 322 | const allocator = testing.allocator; 323 | var refspec = try git.Refspec.parse(allocator, "refs/heads/main", false); 324 | defer refspec.deinit(); 325 | 326 | try testing.expectEqualStrings("refs/heads/main", refspec.getSource()); 327 | // For refspecs without colon, destination might be empty or same as source 328 | // This test just verifies the parse succeeds 329 | } 330 | 331 | test "libgit2 refspec parsing - different src and dst" { 332 | const allocator = testing.allocator; 333 | var refspec = try git.Refspec.parse(allocator, "refs/heads/feature:refs/heads/main", false); 334 | defer refspec.deinit(); 335 | 336 | try testing.expectEqualStrings("refs/heads/feature", refspec.getSource()); 337 | try testing.expectEqualStrings("refs/heads/main", refspec.getDestination()); 338 | } 339 | 340 | test "push - invalid refspec" { 341 | const allocator = testing.allocator; 342 | var arena = std.heap.ArenaAllocator.init(allocator); 343 | defer arena.deinit(); 344 | 345 | var db = try sqlite.Database.open(allocator, ":memory:"); 346 | defer db.close(); 347 | 348 | // Create a test repository 349 | var repo = try createTestRepo(allocator); 350 | defer repo.deinit(); 351 | 352 | var remote = try Remote.init(allocator, &db, &repo); 353 | 354 | const push_cmd = protocol.Command.Push{ 355 | .refspec = "invalid::refspec", 356 | }; 357 | 358 | const result = try remote.push(arena.allocator(), push_cmd); 359 | 360 | try testing.expect(result.results.len == 1); 361 | switch (result.results[0]) { 362 | .ok => unreachable, 363 | .@"error" => |error_info| { 364 | try testing.expectEqualStrings("Invalid refspec format", error_info.why.?); 365 | }, 366 | } 367 | } 368 | 369 | test "push - valid refspec behavior" { 370 | const allocator = testing.allocator; 371 | var arena = std.heap.ArenaAllocator.init(allocator); 372 | defer arena.deinit(); 373 | 374 | var db = try sqlite.Database.open(allocator, ":memory:"); 375 | defer db.close(); 376 | 377 | // Create a test repository 378 | var repo = try createTestRepo(allocator); 379 | defer repo.deinit(); 380 | 381 | var remote = try Remote.init(allocator, &db, &repo); 382 | 383 | // Test with valid refspec - ref resolution should fail 384 | const push_cmd = protocol.Command.Push{ 385 | .refspec = "refs/heads/nonexistent:refs/heads/main", 386 | }; 387 | 388 | const result = try remote.push(arena.allocator(), push_cmd); 389 | 390 | try testing.expect(result.results.len == 1); 391 | switch (result.results[0]) { 392 | .ok => |ref_name| { 393 | // If it succeeds, verify the ref name 394 | try testing.expectEqualStrings("refs/heads/main", ref_name); 395 | }, 396 | .@"error" => |error_info| { 397 | // Should fail due to ref resolution 398 | const error_message = error_info.why.?; 399 | try testing.expect(std.mem.indexOf(u8, error_message, "Failed to resolve reference") != null); 400 | }, 401 | } 402 | } 403 | 404 | test "push - refspec parsing components" { 405 | const allocator = testing.allocator; 406 | 407 | // Test that refspec parsing works for valid cases 408 | var refspec = try git.Refspec.parse(allocator, "refs/heads/main:refs/heads/main", false); 409 | defer refspec.deinit(); 410 | 411 | try testing.expectEqualStrings("refs/heads/main", refspec.getSource()); 412 | try testing.expectEqualStrings("refs/heads/main", refspec.getDestination()); 413 | } 414 | 415 | test "push - database transaction handling" { 416 | const allocator = testing.allocator; 417 | 418 | var db = try sqlite.Database.open(allocator, ":memory:"); 419 | defer db.close(); 420 | 421 | // Create a test repository 422 | var repo = try createTestRepo(allocator); 423 | defer repo.deinit(); 424 | 425 | _ = try Remote.init(allocator, &db, &repo); 426 | 427 | // Test that we can begin and rollback transactions 428 | try db.exec("BEGIN TRANSACTION"); 429 | try db.exec("ROLLBACK"); 430 | 431 | // Test that we can begin and commit transactions 432 | try db.exec("BEGIN TRANSACTION"); 433 | try db.exec("COMMIT"); 434 | } 435 | -------------------------------------------------------------------------------- /src/sqlite.zig: -------------------------------------------------------------------------------- 1 | const std = @import("std"); 2 | const assert = std.debug.assert; 3 | const builtin = @import("builtin"); 4 | const c = @cImport({ 5 | @cInclude("sqlite3.h"); 6 | }); 7 | 8 | comptime { 9 | const expected_sqlite_version = std.SemanticVersion{ 10 | .major = 3, 11 | .minor = 20, 12 | .patch = 0, 13 | }; 14 | const expected_sqlite_version_number = expected_sqlite_version.major * 1000000 + 15 | expected_sqlite_version.minor * 1000 + 16 | expected_sqlite_version.patch; 17 | const actual_sqlite_version_number = c.SQLITE_VERSION_NUMBER; 18 | if (actual_sqlite_version_number < expected_sqlite_version_number) { 19 | @compileError(std.fmt.comptimePrint( 20 | "unsupported sqlite version: expected {}, found {}", 21 | .{ expected_sqlite_version_number, actual_sqlite_version_number }, 22 | )); 23 | } 24 | } 25 | 26 | // --- 27 | 28 | /// Database-specific errors 29 | pub const DatabaseError = error{ 30 | ReadFailed, 31 | WriteFailed, 32 | InitializationFailed, 33 | SchemaError, 34 | }; 35 | 36 | pub const SQLiteError = error{ 37 | /// Generic SQL error or missing database 38 | SQLiteError, 39 | /// Internal logic error in SQLite 40 | SQLiteInternal, 41 | /// Access permission denied 42 | SQLitePerm, 43 | /// Callback routine requested an abort 44 | SQLiteAbort, 45 | /// The database file is locked 46 | SQLiteBusy, 47 | /// A table in the database is locked 48 | SQLiteLocked, 49 | /// A malloc() failed 50 | SQLiteNoMem, 51 | /// Attempt to write a readonly database 52 | SQLiteReadOnly, 53 | /// Operation terminated by sqlite3_interrupt() 54 | SQLiteInterrupt, 55 | /// Some kind of disk I/O error occurred 56 | SQLiteIOErr, 57 | /// The database disk image is malformed 58 | SQLiteCorrupt, 59 | /// Unknown opcode in sqlite3_file_control() 60 | SQLiteNotFound, 61 | /// Insertion failed because database is full 62 | SQLiteFull, 63 | /// Unable to open the database file 64 | SQLiteCantOpen, 65 | /// Database lock protocol error 66 | SQLiteProtocol, 67 | /// The database schema changed 68 | SQLiteSchema, 69 | /// String or BLOB exceeds size limit 70 | SQLiteTooBig, 71 | /// Abort due to constraint violation 72 | SQLiteConstraint, 73 | /// Data type mismatch 74 | SQLiteMismatch, 75 | /// Library used incorrectly 76 | SQLiteMisuse, 77 | /// Uses OS features not supported on host 78 | SQLiteNoLFS, 79 | /// Authorization denied 80 | SQLiteAuth, 81 | /// Not used 82 | SQLiteFormat, 83 | /// 2nd parameter to sqlite3_bind out of range 84 | SQLiteRange, 85 | /// File opened that is not a database file 86 | SQLiteNotADB, 87 | /// Notifications from sqlite3_log() 88 | SQLiteNotice, 89 | /// Warnings from sqlite3_log() 90 | SQLiteWarning, 91 | }; 92 | 93 | /// Convert SQLite result code to Zig error 94 | pub fn errorFromResultCode(code: c_int) SQLiteError { 95 | return switch (code) { 96 | c.SQLITE_ERROR => error.SQLiteError, 97 | c.SQLITE_INTERNAL => error.SQLiteInternal, 98 | c.SQLITE_PERM => error.SQLitePerm, 99 | c.SQLITE_ABORT => error.SQLiteAbort, 100 | c.SQLITE_BUSY => error.SQLiteBusy, 101 | c.SQLITE_LOCKED => error.SQLiteLocked, 102 | c.SQLITE_NOMEM => error.SQLiteNoMem, 103 | c.SQLITE_READONLY => error.SQLiteReadOnly, 104 | c.SQLITE_INTERRUPT => error.SQLiteInterrupt, 105 | c.SQLITE_IOERR => error.SQLiteIOErr, 106 | c.SQLITE_CORRUPT => error.SQLiteCorrupt, 107 | c.SQLITE_NOTFOUND => error.SQLiteNotFound, 108 | c.SQLITE_FULL => error.SQLiteFull, 109 | c.SQLITE_CANTOPEN => error.SQLiteCantOpen, 110 | c.SQLITE_PROTOCOL => error.SQLiteProtocol, 111 | c.SQLITE_SCHEMA => error.SQLiteSchema, 112 | c.SQLITE_TOOBIG => error.SQLiteTooBig, 113 | c.SQLITE_CONSTRAINT => error.SQLiteConstraint, 114 | c.SQLITE_MISMATCH => error.SQLiteMismatch, 115 | c.SQLITE_MISUSE => error.SQLiteMisuse, 116 | c.SQLITE_NOLFS => error.SQLiteNoLFS, 117 | c.SQLITE_AUTH => error.SQLiteAuth, 118 | c.SQLITE_FORMAT => error.SQLiteFormat, 119 | c.SQLITE_RANGE => error.SQLiteRange, 120 | c.SQLITE_NOTADB => error.SQLiteNotADB, 121 | c.SQLITE_NOTICE => error.SQLiteNotice, 122 | c.SQLITE_WARNING => error.SQLiteWarning, 123 | else => error.SQLiteError, 124 | }; 125 | } 126 | 127 | // --- 128 | 129 | pub const SHA_LENGTH = 40; 130 | 131 | comptime { 132 | if (SHA_LENGTH != 40) @compileError("Invalid SHA length"); 133 | } 134 | 135 | /// Git object types 136 | pub const ObjectType = enum { 137 | blob, 138 | tree, 139 | commit, 140 | tag, 141 | 142 | /// Convert enum to string 143 | pub fn toString(self: ObjectType) []const u8 { 144 | return switch (self) { 145 | .blob => "blob", 146 | .tree => "tree", 147 | .commit => "commit", 148 | .tag => "tag", 149 | }; 150 | } 151 | 152 | /// Parse string to enum 153 | pub fn fromString(str: []const u8) ?ObjectType { 154 | if (std.mem.eql(u8, str, "blob")) return .blob; 155 | if (std.mem.eql(u8, str, "tree")) return .tree; 156 | if (std.mem.eql(u8, str, "commit")) return .commit; 157 | if (std.mem.eql(u8, str, "tag")) return .tag; 158 | return null; 159 | } 160 | }; 161 | 162 | pub const objects_schema = 163 | \\CREATE TABLE IF NOT EXISTS git_objects ( 164 | \\ sha TEXT PRIMARY KEY CHECK ( 165 | \\ length (sha) = 40 166 | \\ AND sha GLOB '[0-9a-f]*' 167 | \\ ), 168 | \\ type TEXT NOT NULL CHECK (type IN ('blob', 'tree', 'commit', 'tag')), 169 | \\ data BLOB NOT NULL 170 | \\); 171 | \\CREATE INDEX IF NOT EXISTS idx_git_objects_type ON git_objects (type) 172 | ; 173 | 174 | pub const refs_schema = 175 | \\CREATE TABLE IF NOT EXISTS git_refs ( 176 | \\ name TEXT PRIMARY KEY CHECK (name GLOB 'refs/*'), 177 | \\ sha TEXT NOT NULL, 178 | \\ type TEXT NOT NULL CHECK (type IN ('branch', 'tag', 'remote')), 179 | \\ FOREIGN KEY (sha) REFERENCES git_objects (sha) 180 | \\); 181 | \\CREATE INDEX IF NOT EXISTS idx_git_refs_sha ON git_refs (sha); 182 | \\CREATE TABLE IF NOT EXISTS git_symbolic_refs ( 183 | \\ name TEXT PRIMARY KEY, 184 | \\ target TEXT NOT NULL, 185 | \\ FOREIGN KEY (target) REFERENCES git_refs (name) 186 | \\) 187 | ; 188 | 189 | pub const config_schema = 190 | \\CREATE TABLE IF NOT EXISTS git_config ( 191 | \\ key TEXT PRIMARY KEY, 192 | \\ value TEXT NOT NULL 193 | \\) 194 | ; 195 | 196 | pub const packs_schema = 197 | \\CREATE TABLE IF NOT EXISTS git_packs ( 198 | \\ id INTEGER PRIMARY KEY, 199 | \\ name TEXT NOT NULL UNIQUE, 200 | \\ data BLOB NOT NULL, 201 | \\ index_data BLOB NOT NULL 202 | \\); 203 | \\CREATE INDEX IF NOT EXISTS idx_git_packs_name ON git_packs (name); 204 | \\CREATE TABLE IF NOT EXISTS git_pack_entries ( 205 | \\ pack_id INTEGER NOT NULL, 206 | \\ sha TEXT NOT NULL, 207 | \\ offset INTEGER NOT NULL, 208 | \\ PRIMARY KEY (pack_id, sha), 209 | \\ FOREIGN KEY (pack_id) REFERENCES git_packs (id), 210 | \\ FOREIGN KEY (sha) REFERENCES git_objects (sha) 211 | \\); 212 | \\CREATE INDEX IF NOT EXISTS idx_git_pack_entries_sha ON git_pack_entries (sha) 213 | ; 214 | 215 | /// Initialize all database schemas 216 | pub fn initializeAll(db: *Database) DatabaseError!void { 217 | db.exec(objects_schema) catch return DatabaseError.InitializationFailed; 218 | db.exec(refs_schema) catch return DatabaseError.InitializationFailed; 219 | db.exec(config_schema) catch return DatabaseError.InitializationFailed; 220 | db.exec(packs_schema) catch return DatabaseError.InitializationFailed; 221 | } 222 | 223 | // --- 224 | 225 | pub const ResultSet = struct { 226 | stmt: *c.sqlite3_stmt, 227 | 228 | pub fn next(self: *ResultSet) SQLiteError!bool { 229 | const result = c.sqlite3_step(self.stmt); 230 | return switch (result) { 231 | c.SQLITE_ROW => true, 232 | c.SQLITE_DONE => false, 233 | else => errorFromResultCode(result), 234 | }; 235 | } 236 | 237 | pub fn columnText(self: *ResultSet, index: u32) ?[]const u8 { 238 | const ptr = c.sqlite3_column_text(self.stmt, @intCast(index)); 239 | if (ptr == null) return null; 240 | 241 | const len = c.sqlite3_column_bytes(self.stmt, @intCast(index)); 242 | return @as([*c]const u8, @ptrCast(ptr))[0..@intCast(len)]; 243 | } 244 | 245 | pub fn columnBlob(self: *ResultSet, index: u32) ?[]const u8 { 246 | const ptr = c.sqlite3_column_blob(self.stmt, @intCast(index)); 247 | if (ptr == null) return null; 248 | 249 | const len = c.sqlite3_column_bytes(self.stmt, @intCast(index)); 250 | return @as([*c]const u8, @ptrCast(ptr))[0..@intCast(len)]; 251 | } 252 | }; 253 | 254 | pub const Statement = struct { 255 | stmt: *c.sqlite3_stmt, 256 | 257 | pub fn bindText(self: *Statement, index: u32, text: []const u8) void { 258 | assert(index > 0); // SQLite indices are 1-based 259 | _ = c.sqlite3_bind_text(self.stmt, @intCast(index), text.ptr, @intCast(text.len), null); 260 | } 261 | 262 | pub fn bindBlob(self: *Statement, index: u32, data: []const u8) void { 263 | assert(index > 0); // SQLite indices are 1-based 264 | _ = c.sqlite3_bind_blob(self.stmt, @intCast(index), data.ptr, @intCast(data.len), null); 265 | } 266 | 267 | pub fn execute(self: *Statement) ResultSet { 268 | return ResultSet{ .stmt = self.stmt }; 269 | } 270 | 271 | pub fn finalize(self: *Statement) void { 272 | _ = c.sqlite3_finalize(self.stmt); 273 | } 274 | }; 275 | 276 | pub const Database = struct { 277 | db: *c.sqlite3, 278 | allocator: std.mem.Allocator, 279 | 280 | /// Open SQLite database, creating file if needed 281 | pub fn open(allocator: std.mem.Allocator, path: [:0]const u8) SQLiteError!Database { 282 | assert(path.len > 0); 283 | var db: ?*c.sqlite3 = undefined; 284 | 285 | const result = c.sqlite3_open(path.ptr, &db); 286 | if (result != c.SQLITE_OK) { 287 | if (db) |valid_db| { 288 | _ = c.sqlite3_close(valid_db); 289 | } 290 | return errorFromResultCode(result); 291 | } 292 | assert(db != null); 293 | return Database{ .db = db.?, .allocator = allocator }; 294 | } 295 | 296 | /// Close database connection 297 | pub fn close(self: *const Database) void { 298 | _ = c.sqlite3_close(self.db); 299 | } 300 | 301 | /// Execute non-query SQL statement 302 | pub fn exec(self: *Database, comptime sql: [:0]const u8) SQLiteError!void { 303 | assert(sql.len > 0); 304 | var err_msg: [*c]u8 = undefined; 305 | 306 | const result = c.sqlite3_exec(self.db, sql.ptr, null, null, &err_msg); 307 | if (result != c.SQLITE_OK) { 308 | defer c.sqlite3_free(err_msg); 309 | return errorFromResultCode(result); 310 | } 311 | } 312 | 313 | /// Prepare statement for execution (caller must call finalize()) 314 | pub fn prepare(self: *Database, comptime sql: [:0]const u8) SQLiteError!Statement { 315 | assert(sql.len > 0); 316 | var stmt: ?*c.sqlite3_stmt = undefined; 317 | 318 | const result = c.sqlite3_prepare_v2(self.db, sql.ptr, -1, &stmt, null); 319 | if (result != c.SQLITE_OK) { 320 | return errorFromResultCode(result); 321 | } 322 | assert(stmt != null); 323 | return Statement{ .stmt = stmt.? }; 324 | } 325 | 326 | /// Execute simple statement with automatic cleanup 327 | pub fn executeStatement(self: *Database, comptime sql: [:0]const u8) SQLiteError!void { 328 | var stmt = try self.prepare(sql); 329 | defer stmt.finalize(); 330 | var results = stmt.execute(); 331 | _ = try results.next(); 332 | } 333 | 334 | /// Execute statement with parameters 335 | pub fn execParams(self: *Database, comptime sql: [:0]const u8, params: []const []const u8) SQLiteError!void { 336 | var stmt = try self.prepare(sql); 337 | defer stmt.finalize(); 338 | 339 | for (params, 1..) |param, i| { 340 | stmt.bindText(@intCast(i), param); 341 | } 342 | 343 | var results = stmt.execute(); 344 | _ = try results.next(); 345 | } 346 | 347 | /// Query for single text value (returns allocated string or null) 348 | pub fn oneText(self: *Database, allocator: std.mem.Allocator, comptime sql: [:0]const u8, params: []const []const u8) (SQLiteError || error{OutOfMemory})!?[]const u8 { 349 | var stmt = try self.prepare(sql); 350 | defer stmt.finalize(); 351 | 352 | for (params, 1..) |param, i| { 353 | stmt.bindText(@intCast(i), param); 354 | } 355 | 356 | var results = stmt.execute(); 357 | if (try results.next()) { 358 | const value = results.columnText(0) orelse return null; 359 | return try allocator.dupe(u8, value); 360 | } 361 | return null; 362 | } 363 | 364 | /// Query for multiple text values from first column 365 | pub fn allText(self: *Database, allocator: std.mem.Allocator, comptime sql: [:0]const u8, params: []const []const u8) (SQLiteError || error{OutOfMemory})![][]const u8 { 366 | var stmt = try self.prepare(sql); 367 | defer stmt.finalize(); 368 | 369 | for (params, 1..) |param, i| { 370 | stmt.bindText(@intCast(i), param); 371 | } 372 | 373 | var results = stmt.execute(); 374 | var list = std.ArrayList([]const u8).init(allocator); 375 | errdefer { 376 | for (list.items) |item| { 377 | allocator.free(item); 378 | } 379 | list.deinit(); 380 | } 381 | 382 | while (try results.next()) { 383 | const value = results.columnText(0) orelse continue; 384 | try list.append(try allocator.dupe(u8, value)); 385 | } 386 | 387 | return try list.toOwnedSlice(); 388 | } 389 | }; 390 | 391 | // --- 392 | 393 | /// SQLite implementation for git config storage 394 | pub const ConfigDatabase = struct { 395 | db: *Database, 396 | 397 | const Self = @This(); 398 | 399 | pub const Config = struct { 400 | key: []const u8, 401 | value: []const u8, 402 | 403 | pub fn deinit(self: Config, allocator: std.mem.Allocator) void { 404 | allocator.free(self.key); 405 | allocator.free(self.value); 406 | } 407 | }; 408 | 409 | pub fn init(db: *Database) DatabaseError!Self { 410 | db.exec(config_schema) catch return DatabaseError.InitializationFailed; 411 | return .{ .db = db }; 412 | } 413 | 414 | /// Write config value 415 | pub fn writeConfig(self: *Self, key: []const u8, value: []const u8) DatabaseError!void { 416 | self.db.execParams("INSERT OR REPLACE INTO git_config (key, value) VALUES (?, ?)", &[_][]const u8{ key, value }) catch return DatabaseError.WriteFailed; 417 | } 418 | 419 | /// Read config value 420 | pub fn readConfig(self: *Self, allocator: std.mem.Allocator, key: []const u8) DatabaseError!?[]const u8 { 421 | return self.db.oneText(allocator, "SELECT value FROM git_config WHERE key = ?", &[_][]const u8{key}) catch return DatabaseError.ReadFailed; 422 | } 423 | 424 | /// Unset config key 425 | pub fn unsetConfig(self: *Self, key: []const u8) DatabaseError!void { 426 | self.db.execParams("DELETE FROM git_config WHERE key = ?", &[_][]const u8{key}) catch return DatabaseError.WriteFailed; 427 | } 428 | 429 | /// Iterate over all config entries 430 | pub fn iterateConfig(self: *Self, allocator: std.mem.Allocator) DatabaseError![]Config { 431 | var stmt = self.db.prepare("SELECT key, value FROM git_config ORDER BY key") catch return DatabaseError.ReadFailed; 432 | defer stmt.finalize(); 433 | 434 | var entries = std.ArrayList(Config).init(allocator); 435 | errdefer { 436 | for (entries.items) |entry| { 437 | entry.deinit(allocator); 438 | } 439 | entries.deinit(); 440 | } 441 | var results = stmt.execute(); 442 | 443 | while (results.next() catch return DatabaseError.ReadFailed) { 444 | const key = results.columnText(0) orelse continue; 445 | const value = results.columnText(1) orelse continue; 446 | 447 | entries.append(Config{ 448 | .key = allocator.dupe(u8, key) catch return DatabaseError.ReadFailed, 449 | .value = allocator.dupe(u8, value) catch return DatabaseError.ReadFailed, 450 | }) catch return DatabaseError.ReadFailed; 451 | } 452 | 453 | return entries.toOwnedSlice() catch return DatabaseError.ReadFailed; 454 | } 455 | }; 456 | 457 | 458 | /// SQLite implementation for git objects storage 459 | pub const ObjectDatabase = struct { 460 | db: *Database, 461 | 462 | const Self = @This(); 463 | 464 | pub const Object = struct { 465 | object_type: ObjectType, 466 | data: []const u8, 467 | 468 | pub fn deinit(self: Object, allocator: std.mem.Allocator) void { 469 | allocator.free(self.data); 470 | } 471 | }; 472 | 473 | pub fn init(db: *Database) DatabaseError!Self { 474 | db.exec(objects_schema) catch return DatabaseError.InitializationFailed; 475 | return .{ .db = db }; 476 | } 477 | 478 | /// Write a git object to the database 479 | pub fn writeObject(self: *Self, sha: []const u8, object_type: ObjectType, data: []const u8) DatabaseError!void { 480 | var stmt = self.db.prepare("INSERT OR REPLACE INTO git_objects (sha, type, data) VALUES (?, ?, ?)") catch return DatabaseError.WriteFailed; 481 | defer stmt.finalize(); 482 | 483 | stmt.bindText(1, sha); 484 | stmt.bindText(2, object_type.toString()); 485 | stmt.bindBlob(3, data); 486 | 487 | var results = stmt.execute(); 488 | if (results.next() catch return DatabaseError.WriteFailed) { 489 | // INSERT should not return any rows, so if next() returns true, something is wrong 490 | return DatabaseError.WriteFailed; 491 | } 492 | } 493 | 494 | /// Check if an object exists 495 | pub fn hasObject(self: *Self, sha: []const u8) DatabaseError!bool { 496 | var stmt = self.db.prepare("SELECT 1 FROM git_objects WHERE sha = ?") catch return DatabaseError.ReadFailed; 497 | defer stmt.finalize(); 498 | 499 | stmt.bindText(1, sha); 500 | var results = stmt.execute(); 501 | return results.next() catch return DatabaseError.ReadFailed; 502 | } 503 | 504 | /// Read object data by SHA 505 | pub fn readObject(self: *Self, allocator: std.mem.Allocator, sha: []const u8) DatabaseError!?Object { 506 | var stmt = self.db.prepare("SELECT type, data FROM git_objects WHERE sha = ?") catch return DatabaseError.ReadFailed; 507 | defer stmt.finalize(); 508 | 509 | stmt.bindText(1, sha); 510 | var results = stmt.execute(); 511 | 512 | if (results.next() catch return DatabaseError.ReadFailed) { 513 | const object_type_str = results.columnText(0) orelse return DatabaseError.ReadFailed; 514 | const data = results.columnBlob(1) orelse return DatabaseError.ReadFailed; 515 | 516 | const object_type = ObjectType.fromString(object_type_str) orelse return DatabaseError.ReadFailed; 517 | return Object{ 518 | .object_type = object_type, 519 | .data = allocator.dupe(u8, data) catch return DatabaseError.ReadFailed, 520 | }; 521 | } 522 | 523 | return null; 524 | } 525 | 526 | /// Iterate over objects of a specific type 527 | pub fn iterateObjectsByType(self: *Self, allocator: std.mem.Allocator, object_type: ObjectType) DatabaseError![][]const u8 { 528 | return self.db.allText(allocator, "SELECT sha FROM git_objects WHERE type = ? ORDER BY sha", &[_][]const u8{object_type.toString()}) catch return DatabaseError.ReadFailed; 529 | } 530 | 531 | /// Get total object count 532 | pub fn countObjects(self: *Self, allocator: std.mem.Allocator) DatabaseError!u64 { 533 | const count_str = (self.db.oneText(allocator, "SELECT COUNT(*) FROM git_objects", &[_][]const u8{}) catch return DatabaseError.ReadFailed) orelse return 0; 534 | defer allocator.free(count_str); 535 | 536 | return std.fmt.parseInt(u64, count_str, 10) catch return DatabaseError.ReadFailed; 537 | } 538 | }; 539 | 540 | 541 | /// SQLite implementation for git refs storage 542 | pub const RefDatabase = struct { 543 | db: *Database, 544 | 545 | const Self = @This(); 546 | 547 | pub const Ref = struct { 548 | name: []const u8, 549 | sha: []const u8, 550 | ref_type: []const u8, 551 | 552 | pub fn deinit(self: Ref, allocator: std.mem.Allocator) void { 553 | allocator.free(self.name); 554 | allocator.free(self.sha); 555 | allocator.free(self.ref_type); 556 | } 557 | }; 558 | 559 | pub fn init(db: *Database) DatabaseError!Self { 560 | db.exec(refs_schema) catch return DatabaseError.InitializationFailed; 561 | return .{ .db = db }; 562 | } 563 | 564 | /// Write a reference (regular or symbolic) 565 | pub fn writeRef(self: *Self, name: []const u8, value: []const u8, ref_type: []const u8) DatabaseError!void { 566 | if (std.mem.startsWith(u8, value, "ref: ")) { 567 | const target = value[5..]; 568 | self.db.execParams("INSERT OR REPLACE INTO git_symbolic_refs (name, target) VALUES (?, ?)", &[_][]const u8{ name, target }) catch return DatabaseError.WriteFailed; 569 | } else { 570 | self.db.execParams("INSERT OR REPLACE INTO git_refs (name, sha, type) VALUES (?, ?, ?)", &[_][]const u8{ name, value, ref_type }) catch return DatabaseError.WriteFailed; 571 | } 572 | } 573 | 574 | /// Read reference SHA 575 | pub fn readRef(self: *Self, allocator: std.mem.Allocator, name: []const u8) DatabaseError!?[]const u8 { 576 | return self.db.oneText(allocator, "SELECT sha FROM git_refs WHERE name = ?", &[_][]const u8{name}) catch return DatabaseError.ReadFailed; 577 | } 578 | 579 | /// Iterate over all references (including symbolic refs resolved to their target SHA) 580 | pub fn iterateRefs(self: *Self, allocator: std.mem.Allocator) DatabaseError![]Ref { 581 | var refs = std.ArrayList(Ref).init(allocator); 582 | errdefer { 583 | for (refs.items) |ref| { 584 | ref.deinit(allocator); 585 | } 586 | refs.deinit(); 587 | } 588 | 589 | // First, get all regular refs 590 | var stmt = self.db.prepare("SELECT name, sha, type FROM git_refs ORDER BY name") catch return DatabaseError.ReadFailed; 591 | defer stmt.finalize(); 592 | var results = stmt.execute(); 593 | 594 | while (results.next() catch return DatabaseError.ReadFailed) { 595 | const name = results.columnText(0) orelse continue; 596 | const sha = results.columnText(1) orelse continue; 597 | const ref_type = results.columnText(2) orelse continue; 598 | 599 | refs.append(Ref{ 600 | .name = allocator.dupe(u8, name) catch return DatabaseError.ReadFailed, 601 | .sha = allocator.dupe(u8, sha) catch return DatabaseError.ReadFailed, 602 | .ref_type = allocator.dupe(u8, ref_type) catch return DatabaseError.ReadFailed, 603 | }) catch return DatabaseError.ReadFailed; 604 | } 605 | 606 | // Then, get symbolic refs and resolve them to their target SHA 607 | var symref_stmt = self.db.prepare("SELECT s.name, r.sha, 'symbolic' FROM git_symbolic_refs s JOIN git_refs r ON s.target = r.name ORDER BY s.name") catch return DatabaseError.ReadFailed; 608 | defer symref_stmt.finalize(); 609 | var symref_results = symref_stmt.execute(); 610 | 611 | while (symref_results.next() catch return DatabaseError.ReadFailed) { 612 | const name = symref_results.columnText(0) orelse continue; 613 | const sha = symref_results.columnText(1) orelse continue; 614 | const ref_type = symref_results.columnText(2) orelse continue; 615 | 616 | refs.append(Ref{ 617 | .name = allocator.dupe(u8, name) catch return DatabaseError.ReadFailed, 618 | .sha = allocator.dupe(u8, sha) catch return DatabaseError.ReadFailed, 619 | .ref_type = allocator.dupe(u8, ref_type) catch return DatabaseError.ReadFailed, 620 | }) catch return DatabaseError.ReadFailed; 621 | } 622 | 623 | return refs.toOwnedSlice() catch return DatabaseError.ReadFailed; 624 | } 625 | 626 | /// Delete a reference 627 | pub fn deleteRef(self: *Self, name: []const u8) DatabaseError!void { 628 | self.db.execParams("DELETE FROM git_refs WHERE name = ?", &[_][]const u8{name}) catch return DatabaseError.WriteFailed; 629 | } 630 | 631 | 632 | }; 633 | 634 | 635 | // --- 636 | 637 | const testing = std.testing; 638 | 639 | test "Database open and close" { 640 | const allocator = testing.allocator; 641 | var db = try Database.open(allocator, ":memory:"); 642 | defer db.close(); 643 | 644 | // Just verify that the database was opened successfully by testing a basic operation 645 | try db.exec("CREATE TABLE test (id INTEGER)"); 646 | } 647 | 648 | test "ConfigDatabase set and get" { 649 | const allocator = testing.allocator; 650 | var db = try Database.open(allocator, ":memory:"); 651 | defer db.close(); 652 | 653 | var config_db = try ConfigDatabase.init(&db); 654 | 655 | try config_db.writeConfig("user.name", "John Doe"); 656 | const result = try config_db.readConfig(allocator, "user.name"); 657 | defer if (result) |r| allocator.free(r); 658 | 659 | try testing.expect(result != null); 660 | try testing.expectEqualStrings("John Doe", result.?); 661 | } 662 | 663 | test "ConfigDatabase get non-existent key" { 664 | const allocator = testing.allocator; 665 | var db = try Database.open(allocator, ":memory:"); 666 | defer db.close(); 667 | 668 | var config_db = try ConfigDatabase.init(&db); 669 | 670 | const result = try config_db.readConfig(allocator, "non.existent"); 671 | try testing.expect(result == null); 672 | } 673 | 674 | test "ConfigDatabase listAll" { 675 | const allocator = testing.allocator; 676 | var db = try Database.open(allocator, ":memory:"); 677 | defer db.close(); 678 | 679 | var config_db = try ConfigDatabase.init(&db); 680 | 681 | try config_db.writeConfig("core.editor", "vim"); 682 | try config_db.writeConfig("user.name", "Jane"); 683 | 684 | const entries = try config_db.iterateConfig(allocator); 685 | defer { 686 | for (entries) |entry| { 687 | entry.deinit(allocator); 688 | } 689 | allocator.free(entries); 690 | } 691 | 692 | try testing.expect(entries.len == 2); 693 | try testing.expectEqualStrings("core.editor", entries[0].key); 694 | try testing.expectEqualStrings("vim", entries[0].value); 695 | try testing.expectEqualStrings("user.name", entries[1].key); 696 | try testing.expectEqualStrings("Jane", entries[1].value); 697 | } 698 | 699 | test "ObjectDatabase store and get" { 700 | const allocator = testing.allocator; 701 | var db = try Database.open(allocator, ":memory:"); 702 | defer db.close(); 703 | 704 | var object_db = try ObjectDatabase.init(&db); 705 | 706 | const sha = "abcdef1234567890abcdef1234567890abcdef12"; 707 | const object_type = ObjectType.blob; 708 | const data = "Hello, World!"; 709 | 710 | try object_db.writeObject(sha, object_type, data); 711 | 712 | const result = try object_db.readObject(allocator, sha); 713 | defer if (result) |r| r.deinit(allocator); 714 | 715 | try testing.expect(result != null); 716 | try testing.expect(result.?.object_type == object_type); 717 | try testing.expectEqualStrings(data, result.?.data); 718 | } 719 | 720 | test "ObjectDatabase exists" { 721 | const allocator = testing.allocator; 722 | var db = try Database.open(allocator, ":memory:"); 723 | defer db.close(); 724 | 725 | var object_db = try ObjectDatabase.init(&db); 726 | 727 | const sha = "1111111111111111111111111111111111111111"; 728 | 729 | try testing.expect(!(try object_db.hasObject(sha))); 730 | 731 | try object_db.writeObject(sha, ObjectType.commit, "commit data"); 732 | 733 | try testing.expect(try object_db.hasObject(sha)); 734 | } 735 | 736 | test "RefDatabase setRef and getRef" { 737 | const allocator = testing.allocator; 738 | var db = try Database.open(allocator, ":memory:"); 739 | defer db.close(); 740 | 741 | var ref_db = try RefDatabase.init(&db); 742 | 743 | const ref_name = "refs/heads/main"; 744 | const sha = "abc123def456789"; 745 | const ref_type = "branch"; 746 | 747 | try ref_db.writeRef(ref_name, sha, ref_type); 748 | 749 | const result = try ref_db.readRef(allocator, ref_name); 750 | defer if (result) |r| allocator.free(r); 751 | 752 | try testing.expect(result != null); 753 | try testing.expectEqualStrings(sha, result.?); 754 | } 755 | 756 | 757 | test "RefDatabase listRefs includes symbolic refs resolved to SHA" { 758 | const allocator = testing.allocator; 759 | var db = try Database.open(allocator, ":memory:"); 760 | defer db.close(); 761 | 762 | var ref_db = try RefDatabase.init(&db); 763 | 764 | // Add a regular ref 765 | const ref_name = "refs/heads/main"; 766 | const sha = "abc123def456789"; 767 | try ref_db.writeRef(ref_name, sha, "branch"); 768 | 769 | // Add a symbolic ref pointing to the regular ref 770 | const symbolic_name = "HEAD"; 771 | try ref_db.writeRef(symbolic_name, "ref: refs/heads/main", "symbolic"); 772 | 773 | // List all refs 774 | const refs = try ref_db.iterateRefs(allocator); 775 | defer { 776 | for (refs) |ref| { 777 | ref.deinit(allocator); 778 | } 779 | allocator.free(refs); 780 | } 781 | 782 | // Should have 2 refs: the regular ref and the symbolic ref resolved to same SHA 783 | try testing.expect(refs.len == 2); 784 | 785 | // Find HEAD in the results 786 | var head_found = false; 787 | var main_found = false; 788 | for (refs) |ref| { 789 | if (std.mem.eql(u8, ref.name, "HEAD")) { 790 | head_found = true; 791 | try testing.expectEqualStrings(sha, ref.sha); // HEAD should resolve to same SHA as main 792 | try testing.expectEqualStrings("symbolic", ref.ref_type); 793 | } else if (std.mem.eql(u8, ref.name, "refs/heads/main")) { 794 | main_found = true; 795 | try testing.expectEqualStrings(sha, ref.sha); 796 | try testing.expectEqualStrings("branch", ref.ref_type); 797 | } 798 | } 799 | 800 | try testing.expect(head_found); 801 | try testing.expect(main_found); 802 | } -------------------------------------------------------------------------------- /src/tests.zig: -------------------------------------------------------------------------------- 1 | const std = @import("std"); 2 | 3 | comptime { 4 | _ = @import("cli.zig"); 5 | _ = @import("config.zig"); 6 | _ = @import("git.zig"); 7 | _ = @import("help.zig"); 8 | _ = @import("main.zig"); 9 | _ = @import("protocol.zig"); 10 | _ = @import("remote.zig"); 11 | _ = @import("sqlite.zig"); 12 | _ = @import("transport.zig"); 13 | } -------------------------------------------------------------------------------- /src/transport.zig: -------------------------------------------------------------------------------- 1 | const std = @import("std"); 2 | const assert = std.debug.assert; 3 | const protocol = @import("protocol.zig"); 4 | 5 | // --- 6 | 7 | pub const RemoteUrlError = error{ 8 | InvalidUrl, 9 | InvalidPath, 10 | UnsupportedProtocol, 11 | }; 12 | 13 | pub const RemoteUrl = struct { 14 | protocol: []const u8, 15 | path: []const u8, 16 | }; 17 | 18 | /// Parse and validate sqlite:// URL, preventing path traversal attacks 19 | pub fn parseUrl(allocator: std.mem.Allocator, url: []const u8) RemoteUrlError!RemoteUrl { 20 | // Basic length validation 21 | if (url.len == 0 or url.len > 2048) { 22 | return RemoteUrlError.InvalidUrl; 23 | } 24 | assert(url.len > 0 and url.len <= 2048); 25 | 26 | // Check for null bytes which could cause issues in C code 27 | if (std.mem.indexOf(u8, url, "\x00") != null) { 28 | return RemoteUrlError.InvalidUrl; 29 | } 30 | 31 | // Use standard library parsing 32 | const parsed = std.Uri.parse(url) catch return RemoteUrlError.InvalidUrl; 33 | 34 | // Validate protocol is exactly "sqlite" 35 | if (!std.mem.eql(u8, parsed.scheme, "sqlite")) { 36 | return RemoteUrlError.UnsupportedProtocol; 37 | } 38 | 39 | // Extract the database path - support two formats: 40 | // 1. sqlite://db.sqlite (host contains the database filename) 41 | // 2. sqlite:///path/to/db.sqlite (path contains the database path) 42 | const path = blk: { 43 | if (parsed.host) |host_component| { 44 | if (!parsed.path.isEmpty()) { 45 | // Reject URLs like sqlite://host/path - ambiguous format 46 | return RemoteUrlError.InvalidUrl; 47 | } 48 | // Host-style: sqlite://test.db 49 | break :blk host_component.toRawMaybeAlloc(allocator) catch return RemoteUrlError.InvalidPath; 50 | } else { 51 | // Path-style: sqlite:///path/to/test.db 52 | const raw_path = parsed.path.toRawMaybeAlloc(allocator) catch return RemoteUrlError.InvalidPath; 53 | break :blk raw_path; 54 | } 55 | }; 56 | 57 | // Basic validation for database paths 58 | if (path.len == 0 or path.len > 1024 or std.mem.eql(u8, path, "/")) { 59 | return RemoteUrlError.InvalidPath; 60 | } 61 | 62 | // Use Zig's built-in path resolution to handle .. components safely 63 | // This will normalize the path and detect any attempts to escape 64 | var resolved_components = std.ArrayList([]const u8).init(allocator); 65 | defer resolved_components.deinit(); 66 | 67 | var path_iter = std.fs.path.componentIterator(path) catch return RemoteUrlError.InvalidPath; 68 | while (path_iter.next()) |component| { 69 | if (std.mem.eql(u8, component.name, "..")) { 70 | // Don't allow escaping the current directory context 71 | if (resolved_components.items.len == 0) { 72 | return RemoteUrlError.InvalidPath; 73 | } 74 | _ = resolved_components.pop(); 75 | } else if (std.mem.eql(u8, component.name, ".")) { 76 | // Skip current directory references 77 | continue; 78 | } else { 79 | // Reject suspicious components 80 | if (std.mem.indexOf(u8, component.name, "\x00") != null) { 81 | return RemoteUrlError.InvalidPath; 82 | } 83 | resolved_components.append(component.name) catch return RemoteUrlError.InvalidPath; 84 | } 85 | } 86 | 87 | return RemoteUrl{ 88 | .protocol = parsed.scheme, 89 | .path = path, 90 | }; 91 | } 92 | 93 | // --- 94 | 95 | pub const ProtocolHandler = struct { 96 | in: std.io.AnyReader, 97 | out: std.io.AnyWriter, 98 | err: std.io.AnyWriter, 99 | arena: std.heap.ArenaAllocator, 100 | 101 | pub fn init(allocator: std.mem.Allocator, in: std.io.AnyReader, out: std.io.AnyWriter, err: std.io.AnyWriter) ProtocolHandler { 102 | return .{ 103 | .arena = std.heap.ArenaAllocator.init(allocator), 104 | .in = in, 105 | .out = out, 106 | .err = err, 107 | }; 108 | } 109 | 110 | pub fn deinit(self: *ProtocolHandler) void { 111 | self.arena.deinit(); 112 | } 113 | 114 | /// Run git remote helper protocol loop until EOF 115 | pub fn run(self: *ProtocolHandler, remote: anytype) error{FatalError}!void { 116 | defer _ = self.arena.reset(.free_all); 117 | 118 | while (true) { 119 | defer _ = self.arena.reset(.retain_capacity); 120 | 121 | const cmd = protocol.readCommand(self.arena.allocator(), self.in) catch |err| { 122 | return self.fatalError("Failed to read command: {}\n", .{err}); 123 | } orelse break; // EOF - exit the loop 124 | 125 | const response = self.dispatch(remote, cmd) catch |err| switch (err) { 126 | error.FatalError => return error.FatalError, 127 | else => return self.fatalError("Command failed: {}\n", .{err}), 128 | }; 129 | 130 | response.format("", .{}, self.out) catch |err| switch (err) { 131 | error.BrokenPipe => { 132 | // Git may close the pipe after receiving all data it needs. 133 | // This is expected behavior and not an error. 134 | break; 135 | }, 136 | else => return self.fatalError("Failed to write response: {}\n", .{err}), 137 | }; 138 | } 139 | } 140 | 141 | fn dispatch(self: *ProtocolHandler, remote: anytype, cmd: protocol.Command) !protocol.Response { 142 | const allocator = self.arena.allocator(); 143 | return switch (cmd) { 144 | .capabilities => .{ .capabilities = try remote.capabilities(allocator) }, 145 | .list => |for_push| .{ .list = try remote.list(allocator, for_push) }, 146 | .fetch => |fetch_cmd| .{ .fetch = try remote.fetch(allocator, fetch_cmd) }, 147 | .push => |push_cmd| .{ .push = try remote.push(allocator, push_cmd) }, 148 | .option => |opt| .{ .option = try self.option(opt) }, 149 | .import, .@"export", .connect, .stateless_connect, .get => { 150 | return self.fatalError("Command '{}' not implemented\n", .{cmd}); 151 | }, 152 | }; 153 | } 154 | 155 | pub fn option(self: *ProtocolHandler, opt: protocol.Command.Option) !protocol.Response.Option { 156 | _ = self; 157 | if (std.mem.eql(u8, opt.name, "verbosity")) { 158 | return .ok; 159 | } else if (std.mem.eql(u8, opt.name, "progress")) { 160 | return .unsupported; 161 | } else if (std.mem.eql(u8, opt.name, "timeout")) { 162 | return .unsupported; 163 | } else if (std.mem.eql(u8, opt.name, "depth")) { 164 | return .unsupported; 165 | } else { 166 | return .ok; 167 | } 168 | } 169 | 170 | /// Write fatal error to stderr per git-remote-helpers(7) protocol 171 | fn fatalError(self: *ProtocolHandler, comptime fmt: []const u8, args: anytype) error{FatalError} { 172 | self.err.print(fmt, args) catch {}; 173 | return error.FatalError; 174 | } 175 | }; 176 | 177 | // --- 178 | 179 | const testing = std.testing; 180 | 181 | // URL parsing tests 182 | test "valid URLs" { 183 | const allocator = testing.allocator; 184 | const valid_cases = [_]struct { url: []const u8, expected_path: []const u8 }{ 185 | .{ .url = "sqlite://test.db", .expected_path = "test.db" }, 186 | .{ .url = "sqlite:///tmp/test.db", .expected_path = "/tmp/test.db" }, 187 | .{ .url = "sqlite:///data/app.db", .expected_path = "/data/app.db" }, 188 | .{ .url = "sqlite:test.db", .expected_path = "test.db" }, 189 | }; 190 | 191 | for (valid_cases) |case| { 192 | const result = try parseUrl(allocator, case.url); 193 | try testing.expectEqualStrings("sqlite", result.protocol); 194 | try testing.expectEqualStrings(case.expected_path, result.path); 195 | } 196 | } 197 | 198 | test "rejects directory traversal" { 199 | const allocator = testing.allocator; 200 | const ambiguous_urls = [_][]const u8{ 201 | "sqlite://../../../etc/passwd", // host + path = ambiguous 202 | "sqlite://data/../../../root/.ssh/id_rsa", // host + path = ambiguous 203 | "sqlite://test/../..", // host + path = ambiguous 204 | }; 205 | 206 | const invalid_path_urls = [_][]const u8{ 207 | "sqlite:///../../etc/passwd", // directory traversal in path 208 | "sqlite:../../etc/passwd", // directory traversal in path without leading / 209 | }; 210 | 211 | for (ambiguous_urls) |url| { 212 | try testing.expectError(error.InvalidUrl, parseUrl(allocator, url)); 213 | } 214 | 215 | for (invalid_path_urls) |url| { 216 | try testing.expectError(error.InvalidPath, parseUrl(allocator, url)); 217 | } 218 | } 219 | 220 | test "rejects null bytes" { 221 | const allocator = testing.allocator; 222 | const null_url = "sqlite://test\x00.db"; 223 | try testing.expectError(error.InvalidUrl, parseUrl(allocator, null_url)); 224 | } 225 | 226 | test "rejects malformed URLs" { 227 | const allocator = testing.allocator; 228 | const invalid_url_cases = [_][]const u8{ 229 | "", 230 | "sqlite://data/app.db", // ambiguous host+path format 231 | }; 232 | 233 | const unsupported_protocol_cases = [_][]const u8{ 234 | "notasqliteurl://test.db", 235 | "http://test.db", // wrong protocol 236 | "ftp://test.db", // wrong protocol 237 | }; 238 | 239 | const invalid_path_cases = [_][]const u8{ 240 | "sqlite:", 241 | "sqlite:/", 242 | }; 243 | 244 | for (invalid_url_cases) |url| { 245 | try testing.expectError(error.InvalidUrl, parseUrl(allocator, url)); 246 | } 247 | 248 | for (unsupported_protocol_cases) |url| { 249 | try testing.expectError(error.UnsupportedProtocol, parseUrl(allocator, url)); 250 | } 251 | 252 | for (invalid_path_cases) |url| { 253 | try testing.expectError(error.InvalidPath, parseUrl(allocator, url)); 254 | } 255 | } 256 | 257 | test "rejects oversized URLs" { 258 | const allocator = testing.allocator; 259 | 260 | // Create oversized URL 261 | const long_url = try std.fmt.allocPrint(allocator, "sqlite://{s}", .{"x" ** 2100}); 262 | defer allocator.free(long_url); 263 | 264 | try testing.expectError(error.InvalidUrl, parseUrl(allocator, long_url)); 265 | } 266 | 267 | // Protocol handler tests 268 | test "ProtocolHandler init and deinit" { 269 | var input_data = TestReader.init(""); 270 | var output_data = std.ArrayList(u8).init(testing.allocator); 271 | var err_data = std.ArrayList(u8).init(testing.allocator); 272 | defer output_data.deinit(); 273 | defer err_data.deinit(); 274 | 275 | var test_output = TestWriter.init(&output_data); 276 | var test_err = TestWriter.init(&err_data); 277 | 278 | var handler = ProtocolHandler.init( 279 | testing.allocator, 280 | input_data.reader(), 281 | test_output.writer(), 282 | test_err.writer(), 283 | ); 284 | defer handler.deinit(); 285 | 286 | try testing.expect(handler.arena.child_allocator.ptr == testing.allocator.ptr); 287 | } 288 | 289 | test "ProtocolHandler dispatch capabilities" { 290 | var output_data = std.ArrayList(u8).init(testing.allocator); 291 | defer output_data.deinit(); 292 | 293 | var test_output = TestWriter.init(&output_data); 294 | var empty_reader = TestReader.init(""); 295 | 296 | var handler = ProtocolHandler.init( 297 | testing.allocator, 298 | empty_reader.reader(), 299 | test_output.writer(), 300 | test_output.writer(), 301 | ); 302 | defer handler.deinit(); 303 | 304 | var mock_remote = MockRemote{}; 305 | const response = try handler.dispatch(&mock_remote, .capabilities); 306 | 307 | try testing.expect(response == .capabilities); 308 | try testing.expect(response.capabilities.import == true); 309 | try testing.expect(response.capabilities.@"export" == true); 310 | } 311 | 312 | test "ProtocolHandler dispatch list" { 313 | var empty_reader = TestReader.init(""); 314 | var output_data = std.ArrayList(u8).init(testing.allocator); 315 | defer output_data.deinit(); 316 | 317 | var test_output = TestWriter.init(&output_data); 318 | 319 | var handler = ProtocolHandler.init( 320 | testing.allocator, 321 | empty_reader.reader(), 322 | test_output.writer(), 323 | test_output.writer(), 324 | ); 325 | defer handler.deinit(); 326 | 327 | var mock_remote = MockRemote{}; 328 | const response = try handler.dispatch(&mock_remote, .{ .list = null }); 329 | 330 | try testing.expect(response == .list); 331 | try testing.expect(response.list.refs.len == 0); 332 | } 333 | 334 | test "ProtocolHandler dispatch fetch" { 335 | var empty_reader = TestReader.init(""); 336 | var output_data = std.ArrayList(u8).init(testing.allocator); 337 | defer output_data.deinit(); 338 | 339 | var test_output = TestWriter.init(&output_data); 340 | 341 | var handler = ProtocolHandler.init( 342 | testing.allocator, 343 | empty_reader.reader(), 344 | test_output.writer(), 345 | test_output.writer(), 346 | ); 347 | defer handler.deinit(); 348 | 349 | var mock_remote = MockRemote{}; 350 | const fetch_cmd = protocol.Command.Fetch{ 351 | .sha1 = "abc123", 352 | .name = "refs/heads/main", 353 | }; 354 | const response = try handler.dispatch(&mock_remote, .{ .fetch = fetch_cmd }); 355 | 356 | try testing.expect(response == .fetch); 357 | try testing.expect(response.fetch == .complete); 358 | } 359 | 360 | test "ProtocolHandler handles options" { 361 | var empty_reader = TestReader.init(""); 362 | var output_data = std.ArrayList(u8).init(testing.allocator); 363 | defer output_data.deinit(); 364 | 365 | var test_output = TestWriter.init(&output_data); 366 | 367 | var handler = ProtocolHandler.init( 368 | testing.allocator, 369 | empty_reader.reader(), 370 | test_output.writer(), 371 | test_output.writer(), 372 | ); 373 | defer handler.deinit(); 374 | 375 | var mock_remote = MockRemote{}; 376 | 377 | const verbosity_result = try handler.dispatch(&mock_remote, .{ .option = .{ .name = "verbosity", .value = "1" } }); 378 | try testing.expect(verbosity_result.option == .ok); 379 | 380 | const progress_result = try handler.dispatch(&mock_remote, .{ .option = .{ .name = "progress", .value = "true" } }); 381 | try testing.expect(progress_result.option == .unsupported); 382 | 383 | const depth_result = try handler.dispatch(&mock_remote, .{ .option = .{ .name = "depth", .value = "1" } }); 384 | try testing.expect(depth_result.option == .unsupported); 385 | 386 | const unknown_result = try handler.dispatch(&mock_remote, .{ .option = .{ .name = "unknown", .value = "value" } }); 387 | try testing.expect(unknown_result.option == .ok); 388 | } 389 | 390 | test "ProtocolHandler handles empty input" { 391 | var input_data = TestReader.init(""); 392 | var output_data = std.ArrayList(u8).init(testing.allocator); 393 | defer output_data.deinit(); 394 | 395 | var test_output = TestWriter.init(&output_data); 396 | 397 | var handler = ProtocolHandler.init( 398 | testing.allocator, 399 | input_data.reader(), 400 | test_output.writer(), 401 | test_output.writer(), 402 | ); 403 | defer handler.deinit(); 404 | 405 | var mock_remote = MockRemote{}; 406 | try handler.run(&mock_remote); 407 | 408 | try testing.expectEqual(@as(usize, 0), output_data.items.len); 409 | } 410 | 411 | test "ProtocolHandler handles invalid commands" { 412 | var input_data = TestReader.init("invalid_command\n"); 413 | var output_data = std.ArrayList(u8).init(testing.allocator); 414 | var error_data = std.ArrayList(u8).init(testing.allocator); 415 | defer output_data.deinit(); 416 | defer error_data.deinit(); 417 | 418 | var test_output = TestWriter.init(&output_data); 419 | var test_error = TestWriter.init(&error_data); 420 | 421 | var handler = ProtocolHandler.init( 422 | testing.allocator, 423 | input_data.reader(), 424 | test_output.writer(), 425 | test_error.writer(), 426 | ); 427 | defer handler.deinit(); 428 | 429 | var mock_remote = MockRemote{}; 430 | try testing.expectError(error.FatalError, handler.run(&mock_remote)); 431 | 432 | // Should have written error message to stderr 433 | try testing.expect(error_data.items.len > 0); 434 | try testing.expect(std.mem.indexOf(u8, error_data.items, "Failed to read command") != null); 435 | } 436 | 437 | test "ProtocolHandler handles unimplemented commands" { 438 | var input_data = TestReader.init("import refs/heads/main\n"); 439 | var output_data = std.ArrayList(u8).init(testing.allocator); 440 | var error_data = std.ArrayList(u8).init(testing.allocator); 441 | defer output_data.deinit(); 442 | defer error_data.deinit(); 443 | 444 | var test_output = TestWriter.init(&output_data); 445 | var test_error = TestWriter.init(&error_data); 446 | 447 | var handler = ProtocolHandler.init( 448 | testing.allocator, 449 | input_data.reader(), 450 | test_output.writer(), 451 | test_error.writer(), 452 | ); 453 | defer handler.deinit(); 454 | 455 | var mock_remote = MockRemote{}; 456 | try testing.expectError(error.FatalError, handler.run(&mock_remote)); 457 | 458 | try testing.expect(error_data.items.len > 0); 459 | try testing.expect(std.mem.indexOf(u8, error_data.items, "not implemented") != null); 460 | } 461 | 462 | // Test helpers for mocking remote implementations and I/O 463 | const TestWriter = struct { 464 | data: *std.ArrayList(u8), 465 | 466 | fn init(data: *std.ArrayList(u8)) TestWriter { 467 | return .{ .data = data }; 468 | } 469 | 470 | fn writer(self: *TestWriter) std.io.AnyWriter { 471 | return .{ 472 | .context = @ptrCast(self), 473 | .writeFn = writeFn, 474 | }; 475 | } 476 | 477 | fn writeFn(context: *const anyopaque, bytes: []const u8) anyerror!usize { 478 | const self: *TestWriter = @ptrCast(@alignCast(@constCast(context))); 479 | try self.data.appendSlice(bytes); 480 | return bytes.len; 481 | } 482 | }; 483 | 484 | const TestReader = struct { 485 | data: []const u8, 486 | pos: usize = 0, 487 | 488 | fn init(data: []const u8) TestReader { 489 | return .{ .data = data }; 490 | } 491 | 492 | fn reader(self: *TestReader) std.io.AnyReader { 493 | return .{ 494 | .context = @ptrCast(self), 495 | .readFn = readFn, 496 | }; 497 | } 498 | 499 | fn readFn(context: *const anyopaque, buffer: []u8) anyerror!usize { 500 | const self: *TestReader = @ptrCast(@alignCast(@constCast(context))); 501 | if (self.pos >= self.data.len) return 0; 502 | const available = self.data.len - self.pos; 503 | const to_read = @min(buffer.len, available); 504 | @memcpy(buffer[0..to_read], self.data[self.pos .. self.pos + to_read]); 505 | self.pos += to_read; 506 | return to_read; 507 | } 508 | }; 509 | 510 | // Simple mock remote for testing 511 | const MockRemote = struct { 512 | fn capabilities(_: *MockRemote, _: std.mem.Allocator) !protocol.Response.Capabilities { 513 | return .{ 514 | .import = true, 515 | .@"export" = true, 516 | .push = true, 517 | .fetch = true, 518 | .connect = false, 519 | .progress = true, 520 | .refspec = null, 521 | .option = true, 522 | }; 523 | } 524 | 525 | fn list(_: *MockRemote, _: std.mem.Allocator, _: ?protocol.Command.List) !protocol.Response.List { 526 | return .{ .refs = &[_]protocol.Ref{} }; 527 | } 528 | 529 | fn fetch(_: *MockRemote, _: std.mem.Allocator, _: protocol.Command.Fetch) !protocol.Response.Fetch { 530 | return .complete; 531 | } 532 | 533 | fn push(_: *MockRemote, allocator: std.mem.Allocator, push_cmd: protocol.Command.Push) !protocol.Response.Push { 534 | const ref_name = try allocator.dupe(u8, push_cmd.refspec); 535 | const results = try allocator.alloc(protocol.Response.Push.PushResult, 1); 536 | results[0] = .{ .ok = ref_name }; 537 | return .{ .results = results }; 538 | } 539 | }; --------------------------------------------------------------------------------