⚘ use your pds as a git remote if you want to ⚘
5
fork

Configure Feed

Select the types of activity you want to include in your feed.

more implementation

notplants adbd7fe8 d6b652f6

+2885 -24
+227
Cargo.lock
··· 3 3 version = 4 4 4 5 5 [[package]] 6 + name = "aho-corasick" 7 + version = "1.1.4" 8 + source = "registry+https://github.com/rust-lang/crates.io-index" 9 + checksum = "ddd31a130427c27518df266943a5308ed92d4b226cc639f5a8f1002816174301" 10 + dependencies = [ 11 + "memchr", 12 + ] 13 + 14 + [[package]] 15 + name = "anstream" 16 + version = "0.6.21" 17 + source = "registry+https://github.com/rust-lang/crates.io-index" 18 + checksum = "43d5b281e737544384e969a5ccad3f1cdd24b48086a0fc1b2a5262a26b8f4f4a" 19 + dependencies = [ 20 + "anstyle", 21 + "anstyle-parse", 22 + "anstyle-query", 23 + "anstyle-wincon", 24 + "colorchoice", 25 + "is_terminal_polyfill", 26 + "utf8parse", 27 + ] 28 + 29 + [[package]] 30 + name = "anstyle" 31 + version = "1.0.13" 32 + source = "registry+https://github.com/rust-lang/crates.io-index" 33 + checksum = "5192cca8006f1fd4f7237516f40fa183bb07f8fbdfedaa0036de5ea9b0b45e78" 34 + 35 + [[package]] 36 + name = "anstyle-parse" 37 + version = "0.2.7" 38 + source = "registry+https://github.com/rust-lang/crates.io-index" 39 + checksum = "4e7644824f0aa2c7b9384579234ef10eb7efb6a0deb83f9630a49594dd9c15c2" 40 + dependencies = [ 41 + "utf8parse", 42 + ] 43 + 44 + [[package]] 45 + name = "anstyle-query" 46 + version = "1.1.5" 47 + source = "registry+https://github.com/rust-lang/crates.io-index" 48 + checksum = "40c48f72fd53cd289104fc64099abca73db4166ad86ea0b4341abe65af83dadc" 49 + dependencies = [ 50 + "windows-sys 0.61.2", 51 + ] 52 + 53 + [[package]] 54 + name = "anstyle-wincon" 55 + version = "3.0.11" 56 + source = "registry+https://github.com/rust-lang/crates.io-index" 57 + checksum = "291e6a250ff86cd4a820112fb8898808a366d8f9f58ce16d1f538353ad55747d" 58 + dependencies = [ 59 + "anstyle", 60 + "once_cell_polyfill", 61 + "windows-sys 0.61.2", 62 + ] 63 + 64 + [[package]] 6 65 name = "anyhow" 7 66 version = "1.0.102" 8 67 source = "registry+https://github.com/rust-lang/crates.io-index" ··· 59 118 version = "0.2.1" 60 119 source = "registry+https://github.com/rust-lang/crates.io-index" 61 120 checksum = "613afe47fcd5fac7ccf1db93babcb082c5994d996f20b8b159f2ad1658eb5724" 121 + 122 + [[package]] 123 + name = "clap" 124 + version = "4.5.60" 125 + source = "registry+https://github.com/rust-lang/crates.io-index" 126 + checksum = "2797f34da339ce31042b27d23607e051786132987f595b02ba4f6a6dffb7030a" 127 + dependencies = [ 128 + "clap_builder", 129 + "clap_derive", 130 + ] 131 + 132 + [[package]] 133 + name = "clap_builder" 134 + version = "4.5.60" 135 + source = "registry+https://github.com/rust-lang/crates.io-index" 136 + checksum = "24a241312cea5059b13574bb9b3861cabf758b879c15190b37b6d6fd63ab6876" 137 + dependencies = [ 138 + "anstream", 139 + "anstyle", 140 + "clap_lex", 141 + "strsim", 142 + ] 143 + 144 + [[package]] 145 + name = "clap_derive" 146 + version = "4.5.55" 147 + source = "registry+https://github.com/rust-lang/crates.io-index" 148 + checksum = "a92793da1a46a5f2a02a6f4c46c6496b28c43638adea8306fcb0caa1634f24e5" 149 + dependencies = [ 150 + "heck", 151 + "proc-macro2", 152 + "quote", 153 + "syn", 154 + ] 155 + 156 + [[package]] 157 + name = "clap_lex" 158 + version = "1.0.0" 159 + source = "registry+https://github.com/rust-lang/crates.io-index" 160 + checksum = "3a822ea5bc7590f9d40f1ba12c0dc3c2760f3482c6984db1573ad11031420831" 161 + 162 + [[package]] 163 + name = "colorchoice" 164 + version = "1.0.4" 165 + source = "registry+https://github.com/rust-lang/crates.io-index" 166 + checksum = "b05b61dc5112cbb17e4b6cd61790d9845d13888356391624cbe7e41efeac1e75" 62 167 63 168 [[package]] 64 169 name = "displaydoc" ··· 445 550 ] 446 551 447 552 [[package]] 553 + name = "is_terminal_polyfill" 554 + version = "1.70.2" 555 + source = "registry+https://github.com/rust-lang/crates.io-index" 556 + checksum = "a6cb138bb79a146c1bd460005623e142ef0181e3d0219cb493e02f7d08a35695" 557 + 558 + [[package]] 448 559 name = "itoa" 449 560 version = "1.0.17" 450 561 source = "registry+https://github.com/rust-lang/crates.io-index" ··· 461 572 ] 462 573 463 574 [[package]] 575 + name = "lazy_static" 576 + version = "1.5.0" 577 + source = "registry+https://github.com/rust-lang/crates.io-index" 578 + checksum = "bbd2bcb4c963f2ddae06a2efc7e9f3591312473c50c6685e1f298068316e66fe" 579 + 580 + [[package]] 464 581 name = "leb128fmt" 465 582 version = "0.1.0" 466 583 source = "registry+https://github.com/rust-lang/crates.io-index" ··· 506 623 checksum = "112b39cec0b298b6c1999fee3e31427f74f676e4cb9879ed1a121b43661a4154" 507 624 508 625 [[package]] 626 + name = "matchers" 627 + version = "0.2.0" 628 + source = "registry+https://github.com/rust-lang/crates.io-index" 629 + checksum = "d1525a2a28c7f4fa0fc98bb91ae755d1e2d1505079e05539e35bc876b5d65ae9" 630 + dependencies = [ 631 + "regex-automata", 632 + ] 633 + 634 + [[package]] 509 635 name = "memchr" 510 636 version = "2.8.0" 511 637 source = "registry+https://github.com/rust-lang/crates.io-index" ··· 523 649 ] 524 650 525 651 [[package]] 652 + name = "nu-ansi-term" 653 + version = "0.50.3" 654 + source = "registry+https://github.com/rust-lang/crates.io-index" 655 + checksum = "7957b9740744892f114936ab4a57b3f487491bbeafaf8083688b16841a4240e5" 656 + dependencies = [ 657 + "windows-sys 0.61.2", 658 + ] 659 + 660 + [[package]] 526 661 name = "once_cell" 527 662 version = "1.21.3" 528 663 source = "registry+https://github.com/rust-lang/crates.io-index" 529 664 checksum = "42f5e15c9953c5e4ccceeb2e7382a716482c34515315f7b03532b8b4e8393d2d" 665 + 666 + [[package]] 667 + name = "once_cell_polyfill" 668 + version = "1.70.2" 669 + source = "registry+https://github.com/rust-lang/crates.io-index" 670 + checksum = "384b8ab6d37215f3c5301a95a4accb5d64aa607f1fcb26a11b5303878451b4fe" 530 671 531 672 [[package]] 532 673 name = "parking_lot" ··· 555 696 name = "pds-git-remote" 556 697 version = "0.1.0" 557 698 dependencies = [ 699 + "clap", 558 700 "reqwest", 559 701 "serde", 560 702 "serde_json", 561 703 "tempfile", 562 704 "tokio", 563 705 "tracing", 706 + "tracing-subscriber", 564 707 ] 565 708 566 709 [[package]] ··· 727 870 ] 728 871 729 872 [[package]] 873 + name = "regex-automata" 874 + version = "0.4.14" 875 + source = "registry+https://github.com/rust-lang/crates.io-index" 876 + checksum = "6e1dd4122fc1595e8162618945476892eefca7b88c52820e74af6262213cae8f" 877 + dependencies = [ 878 + "aho-corasick", 879 + "memchr", 880 + "regex-syntax", 881 + ] 882 + 883 + [[package]] 884 + name = "regex-syntax" 885 + version = "0.8.9" 886 + source = "registry+https://github.com/rust-lang/crates.io-index" 887 + checksum = "a96887878f22d7bad8a3b6dc5b7440e0ada9a245242924394987b21cf2210a4c" 888 + 889 + [[package]] 730 890 name = "reqwest" 731 891 version = "0.12.28" 732 892 source = "registry+https://github.com/rust-lang/crates.io-index" ··· 912 1072 ] 913 1073 914 1074 [[package]] 1075 + name = "sharded-slab" 1076 + version = "0.1.7" 1077 + source = "registry+https://github.com/rust-lang/crates.io-index" 1078 + checksum = "f40ca3c46823713e0d4209592e8d6e826aa57e928f09752619fc696c499637f6" 1079 + dependencies = [ 1080 + "lazy_static", 1081 + ] 1082 + 1083 + [[package]] 915 1084 name = "shlex" 916 1085 version = "1.3.0" 917 1086 source = "registry+https://github.com/rust-lang/crates.io-index" ··· 956 1125 checksum = "6ce2be8dc25455e1f91df71bfa12ad37d7af1092ae736f3a6cd0e37bc7810596" 957 1126 958 1127 [[package]] 1128 + name = "strsim" 1129 + version = "0.11.1" 1130 + source = "registry+https://github.com/rust-lang/crates.io-index" 1131 + checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f" 1132 + 1133 + [[package]] 959 1134 name = "subtle" 960 1135 version = "2.6.1" 961 1136 source = "registry+https://github.com/rust-lang/crates.io-index" ··· 1023 1198 "proc-macro2", 1024 1199 "quote", 1025 1200 "syn", 1201 + ] 1202 + 1203 + [[package]] 1204 + name = "thread_local" 1205 + version = "1.1.9" 1206 + source = "registry+https://github.com/rust-lang/crates.io-index" 1207 + checksum = "f60246a4944f24f6e018aa17cdeffb7818b76356965d03b07d6a9886e8962185" 1208 + dependencies = [ 1209 + "cfg-if", 1026 1210 ] 1027 1211 1028 1212 [[package]] ··· 1139 1323 source = "registry+https://github.com/rust-lang/crates.io-index" 1140 1324 checksum = "63e71662fa4b2a2c3a26f570f037eb95bb1f85397f3cd8076caed2f026a6d100" 1141 1325 dependencies = [ 1326 + "log", 1142 1327 "pin-project-lite", 1143 1328 "tracing-attributes", 1144 1329 "tracing-core", ··· 1162 1347 checksum = "db97caf9d906fbde555dd62fa95ddba9eecfd14cb388e4f491a66d74cd5fb79a" 1163 1348 dependencies = [ 1164 1349 "once_cell", 1350 + "valuable", 1351 + ] 1352 + 1353 + [[package]] 1354 + name = "tracing-log" 1355 + version = "0.2.0" 1356 + source = "registry+https://github.com/rust-lang/crates.io-index" 1357 + checksum = "ee855f1f400bd0e5c02d150ae5de3840039a3f54b025156404e34c23c03f47c3" 1358 + dependencies = [ 1359 + "log", 1360 + "once_cell", 1361 + "tracing-core", 1362 + ] 1363 + 1364 + [[package]] 1365 + name = "tracing-subscriber" 1366 + version = "0.3.22" 1367 + source = "registry+https://github.com/rust-lang/crates.io-index" 1368 + checksum = "2f30143827ddab0d256fd843b7a66d164e9f271cfa0dde49142c5ca0ca291f1e" 1369 + dependencies = [ 1370 + "matchers", 1371 + "nu-ansi-term", 1372 + "once_cell", 1373 + "regex-automata", 1374 + "sharded-slab", 1375 + "smallvec", 1376 + "thread_local", 1377 + "tracing", 1378 + "tracing-core", 1379 + "tracing-log", 1165 1380 ] 1166 1381 1167 1382 [[package]] ··· 1205 1420 version = "1.0.4" 1206 1421 source = "registry+https://github.com/rust-lang/crates.io-index" 1207 1422 checksum = "b6c140620e7ffbb22c2dee59cafe6084a59b5ffc27a8859a5f0d494b5d52b6be" 1423 + 1424 + [[package]] 1425 + name = "utf8parse" 1426 + version = "0.2.2" 1427 + source = "registry+https://github.com/rust-lang/crates.io-index" 1428 + checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821" 1429 + 1430 + [[package]] 1431 + name = "valuable" 1432 + version = "0.1.1" 1433 + source = "registry+https://github.com/rust-lang/crates.io-index" 1434 + checksum = "ba73ea9cf16a25df0c8caa16c51acb937d5712a8429db78a3ee29d5dcacd3a65" 1208 1435 1209 1436 [[package]] 1210 1437 name = "want"
+10 -1
Cargo.toml
··· 5 5 6 6 [workspace] 7 7 8 + [[bin]] 9 + name = "git-remote-pds" 10 + path = "src/main.rs" 11 + 8 12 [dependencies] 13 + clap = { version = "4.5", features = ["derive"] } 9 14 reqwest = { version = "0.12", features = ["json", "rustls-tls"], default-features = false } 10 15 serde = { version = "1.0", features = ["derive"] } 11 16 serde_json = "1.0" 12 17 tempfile = "3.13.0" 13 18 tokio = { version = "1", features = ["full"] } 14 - tracing = "0.1" 19 + tracing = { version = "0.1", features = ["log"] } 20 + tracing-subscriber = { version = "0.3", features = ["env-filter", "fmt"] } 21 + 22 + [features] 23 + e2e = [] 15 24 16 25 [dev-dependencies] 17 26 tempfile = "3.13.0"
+71
e2e-testing.md
··· 1 + # E2E Testing for pds-git-remote against Local PDS 2 + 3 + ## Context 4 + 5 + Phases 1-3.1 of pds-git-remote are implemented: core types, PDS client, bundle operations, push flow, and local PDS dev scripts. But the push tests only verify offline logic (empty repos, unreachable PDS, bundle creation). We need end-to-end tests that actually login to a PDS, upload blobs, write records, and verify the full push round-trip works. 6 + 7 + ## Approach 8 + 9 + ### 1. Add `e2e` feature flag to Cargo.toml 10 + 11 + **File:** `crates/pds-git-remote/Cargo.toml` 12 + 13 + Add a `[features]` section with a marker feature: 14 + ```toml 15 + [features] 16 + e2e = [] 17 + ``` 18 + 19 + No dependency changes — just a gate for test compilation. 20 + 21 + ### 2. Create E2E test file 22 + 23 + **File:** `crates/pds-git-remote/tests/e2e_tests.rs` (new) 24 + 25 + Gated with `#![cfg(feature = "e2e")]` at the module level. 26 + 27 + **PDS detection:** A `pds_is_available()` helper checks `http://localhost:3000/xrpc/_health`. A `require_pds!()` macro at the top of each test skips gracefully if the PDS isn't running — so developers can run `cargo test --features e2e` without the harness and just get skips. 28 + 29 + **Constants:** `PDS_URL`, `TEST_HANDLE`, `TEST_PASSWORD` matching the defaults from `scripts/pds-dev/create-account.sh`. 30 + 31 + **Helper:** `login_client()` → `(PdsClient, did)` — logs in and returns an authenticated client. 32 + 33 + **Test cases (5):** 34 + 35 + | Test | What it verifies | 36 + |------|-----------------| 37 + | `e2e_login` | `PdsClient::login()` returns valid DID, tokens, handle | 38 + | `e2e_first_push` | Full push flow: commit → `push()` → state record created with 1 bundle, no prerequisites | 39 + | `e2e_incremental_push` | Two pushes: state record grows to 2 bundles, second has prerequisites, swap_record CAS works | 40 + | `e2e_already_up_to_date` | Push twice with no new commits → `PushResult::AlreadyUpToDate` | 41 + | `e2e_blob_round_trip` | `upload_blob()` then `get_blob()` returns identical bytes | 42 + 43 + Each test uses a unique `repo_name` rkey to avoid collision. 44 + 45 + ### 3. Create test harness script 46 + 47 + **File:** `scripts/pds-dev/run-e2e.sh` (new, executable) 48 + 49 + Flow: 50 + 1. `reset.sh` — clean slate 51 + 2. `start.sh` — start PDS, wait for health check 52 + 3. `create-account.sh` — create test account 53 + 4. `cargo test -p pds-git-remote --features e2e -- --test-threads=1` 54 + 5. `stop.sh` on exit (via `trap`, unless `--keep` flag passed) 55 + 56 + Tests run serialized (`--test-threads=1`) since they share one PDS account. 57 + 58 + ## Files to create/modify 59 + 60 + | File | Action | 61 + |------|--------| 62 + | `crates/pds-git-remote/Cargo.toml` | Add `[features]` section | 63 + | `crates/pds-git-remote/tests/e2e_tests.rs` | New: 5 E2E tests + helpers | 64 + | `scripts/pds-dev/run-e2e.sh` | New: harness script | 65 + 66 + ## Verification 67 + 68 + - `cargo test -p pds-git-remote --quiet` — existing 26 tests still pass (E2E tests not compiled) 69 + - `cargo test -p pds-git-remote --features e2e --quiet` — compiles E2E tests, skips gracefully if no PDS 70 + - `./scripts/pds-dev/run-e2e.sh` — full E2E suite against real PDS (requires Docker) 71 + - `cargo test -p lichen-cms -p lichen-server --quiet` — workspace unaffected
+53 -23
plan.md
··· 17 17 18 18 --- 19 19 20 + ## Reference libraries 21 + 22 + ### Primary: atrium (recommended) 23 + 24 + Use [atrium](https://github.com/atrium-rs/atrium) as the primary reference for Rust AT Protocol patterns. It is the de facto standard Rust AT Protocol library — actively maintained, published on crates.io, ~400 stars, used in production by other projects. Key patterns: 25 + 26 + - **Trait layering**: `HttpClient` → `XrpcClient` → `SessionManager` → `Agent`. Each layer adds one concern. We don't need this level of abstraction but should follow the same separation of auth from transport. 27 + - **Per-request auth**: auth tokens are injected per-request via `authorization_token()`, not stored on the HTTP client. Keeps the client immutable and avoids races during token refresh. 28 + - **`InputDataOrBytes` / `OutputDataOrBytes`**: clean separation between JSON payloads and raw byte payloads (blobs) in a single API surface. Blob upload sends `InputDataOrBytes::Bytes(Vec<u8>)`. 29 + - **Validated string newtypes**: `Did`, `Handle`, `Nsid`, `Tid`, `RecordKey`, etc. — all use a `string_newtype!` macro with validation at construction via `FromStr` and `Deserialize`. 30 + - **`Object<T>` wrapper**: generated structs are wrapped to capture unknown fields via `#[serde(flatten)]` for forward compatibility. 31 + - **Generic `Store<K, V>` trait**: pluggable session persistence (memory, file, etc.) via an async trait with `get/set/del/clear`. 32 + - **Blob upload/download**: `agent.api.com.atproto.repo.upload_blob(bytes)` returns a `BlobRef` with CID; `agent.api.com.atproto.sync.get_blob(params)` returns raw bytes. 33 + - **Hierarchical namespace API**: `agent.api.com.atproto.repo.create_record(...)` mirrors the lexicon namespace. 34 + 35 + ### Secondary: atproto-rs 36 + 37 + Use [atproto-rs](https://github.com/dollspace-gay/atproto-rs/tree/main) as a secondary reference (less mature, single contributor, but readable code). Useful patterns: 38 + 39 + - **Atomic session commits**: read old state → network call with per-request auth → write new state in a single `RwLock` write. Login, refresh, and resume all follow this pattern. 40 + - **`serde_json::Value` as XRPC interchange type**: the XRPC client returns `Value`; callers deserialize as needed. Simpler than atrium's generated types — closer to what we want. 41 + - **XRPC client shape**: `XrpcClient { service: Url, client: reqwest::Client, headers: HashMap }` with `query()` for GET and `procedure()` for POST. 42 + 43 + ### Our approach 44 + 45 + - **`anyhow` for errors**: use `anyhow::Result` throughout for parity with the rest of the lichen codebase. No `thiserror`. 46 + - Keep it simpler than either reference — we only need a handful of XRPC calls, not a full SDK. 47 + 48 + --- 49 + 20 50 ## Phase 1: Core types and PDS client 21 51 22 52 Foundation layer — types that model the PDS state record and an HTTP client for the PDS XRPC API. ··· 61 91 62 92 Combine the PDS client and bundle operations into a complete push. 63 93 64 - - [ ] implement push logic in `push.rs`: 94 + - [x] implement push logic in `push.rs`: 65 95 - read current state record from PDS (or handle first-push where none exists) 66 96 - determine what's new: compare local refs to remote refs 67 97 - create incremental bundle (or full bundle on first push) 68 98 - chunk if needed, upload blob(s) via `upload_blob` 69 99 - append new `BundleEntry` to state, update refs 70 100 - write updated state record via `put_record` 71 - - [ ] handle edge cases: 101 + - [x] handle edge cases: 72 102 - first push (no existing state record) → create full bundle + new record 73 103 - nothing to push (refs match) → no-op 74 104 - non-fast-forward → reject with error (no force push for now) 75 - - [ ] add integration tests with a mock PDS server (or test against local PDS) 105 + - [x] add integration tests with a mock PDS server (or test against local PDS) 76 106 77 107 --- 78 108 ··· 80 110 81 111 Set up a local PDS server via Docker for integration testing. Scripts live in `scripts/pds-dev/`. 82 112 83 - - [ ] create `scripts/pds-dev/compose.yaml`: 113 + - [x] create `scripts/pds-dev/compose.yaml`: 84 114 - single service: `ghcr.io/bluesky-social/pds:0.4` on port 3000 85 115 - volume mount `./pds-data:/pds` for persistence 86 116 - env_file pointing to `pds.env` 87 - - [ ] create `scripts/pds-dev/setup.sh`: 117 + - [x] create `scripts/pds-dev/setup.sh`: 88 118 - generate secrets (`PDS_JWT_SECRET`, `PDS_ADMIN_PASSWORD`, rotation key) 89 119 - write `pds.env` with local-dev defaults (`PDS_HOSTNAME=localhost`, `PDS_DEV_MODE=true`, `PDS_INVITE_REQUIRED=false`) 90 120 - create data directory 91 121 - print admin password for reference 92 - - [ ] create `scripts/pds-dev/start.sh`: 122 + - [x] create `scripts/pds-dev/start.sh`: 93 123 - run `setup.sh` if `pds.env` doesn't exist yet 94 124 - `docker compose up -d` 95 125 - wait for health check (`/xrpc/_health`) with timeout 96 - - [ ] create `scripts/pds-dev/create-account.sh`: 126 + - [x] create `scripts/pds-dev/create-account.sh`: 97 127 - accept handle and password as args (defaults: `test.localhost` / `test-password-123`) 98 128 - call `com.atproto.server.createAccount` XRPC endpoint 99 129 - print the DID and access token 100 - - [ ] create `scripts/pds-dev/login.sh`: 130 + - [x] create `scripts/pds-dev/login.sh`: 101 131 - call `com.atproto.server.createSession` for a given handle/password 102 132 - print `accessJwt` for use in manual testing or piping to other scripts 103 - - [ ] create `scripts/pds-dev/stop.sh`: 133 + - [x] create `scripts/pds-dev/stop.sh`: 104 134 - `docker compose down` 105 - - [ ] create `scripts/pds-dev/reset.sh`: 135 + - [x] create `scripts/pds-dev/reset.sh`: 106 136 - `docker compose down -v` and `rm -rf pds-data` to wipe all state 107 - - [ ] add `scripts/pds-dev/README.md` with quick-start instructions 108 - - [ ] add `scripts/pds-dev/` to `.gitignore` for `pds-data/` and `pds.env` (generated secrets) 137 + - [x] add `scripts/pds-dev/README.md` with quick-start instructions 138 + - [x] add `scripts/pds-dev/` to `.gitignore` for `pds-data/` and `pds.env` (generated secrets) 109 139 110 140 --- 111 141 ··· 113 143 114 144 Download bundle chain from PDS and apply to local repo. 115 145 116 - - [ ] implement fetch/clone logic in `fetch.rs`: 146 + - [x] implement fetch/clone logic in `fetch.rs`: 117 147 - resolve `pds://handle/repo-name` into DID + PDS endpoint + rkey 118 148 - read state record → get bundle chain and refs 119 149 - for clone: download all bundles in order (oldest first), apply each 120 150 - for fetch: compare local refs to remote, skip bundles whose tips we already have, download and apply only new ones 121 151 - set up local refs from state record 122 - - [ ] handle chunked bundles (reassemble parts before unbundling) 123 - - [ ] add integration tests 152 + - [x] handle chunked bundles (reassemble parts before unbundling) 153 + - [x] add integration tests 124 154 125 155 --- 126 156 ··· 128 158 129 159 Implement the `git-remote-pds` binary that speaks git's remote helper protocol on stdin/stdout. 130 160 131 - - [ ] add `[[bin]]` target to `Cargo.toml` for `git-remote-pds` 132 - - [ ] implement remote helper protocol in `remote_helper.rs`: 161 + - [x] add `[[bin]]` target to `Cargo.toml` for `git-remote-pds` 162 + - [x] implement remote helper protocol in `remote_helper.rs`: 133 163 - `capabilities` → respond with `push` and `fetch` 134 164 - `list` / `list for-push` → read refs from PDS state record 135 165 - `fetch <sha> <ref>` → download and apply bundle chain 136 166 - `push <src>:<dst>` → create bundle, upload, update state 137 - - [ ] implement CLI auth in `auth.rs`: 138 - - `pds-git auth login` → open browser for atproto OAuth, cache token locally 139 - - token storage in `~/.config/pds-git-remote/` or platform-appropriate config dir 140 - - token refresh on expiry 141 - - [ ] add `clap`-based CLI for auth subcommands 142 - - [ ] end-to-end test: init repo, add remote, push, clone elsewhere, verify content matches 167 + - [x] implement CLI auth in `auth.rs`: 168 + - `pds-git auth login` → login via createSession, cache token locally 169 + - token storage in `~/.config/pds-git-remote/auth.json` 170 + - env var auth (`PDS_ACCESS_TOKEN`/`PDS_DID` or `PDS_HANDLE`/`PDS_PASSWORD`) 171 + - [x] add `clap`-based CLI for auth subcommands 172 + - [x] end-to-end test: init repo, add remote, push, clone elsewhere, verify content matches 143 173 144 174 --- 145 175
+34
scripts/nixtests/README.md
··· 1 + # Nix-based PDS scripts 2 + 3 + Run a local ATProto PDS using the `bluesky-pds` package from nixpkgs (no Docker required). 4 + 5 + ## Prerequisites 6 + 7 + ```bash 8 + nix develop # enters dev shell with `pds` on PATH 9 + ``` 10 + 11 + ## Quick start 12 + 13 + ```bash 14 + ./scripts/nixtests/start.sh # generates secrets on first run, starts PDS 15 + ./scripts/nixtests/create-account.sh # creates test.localhost account 16 + ./scripts/nixtests/login.sh # prints access token 17 + ``` 18 + 19 + ## Scripts 20 + 21 + | Script | Description | 22 + |--------|-------------| 23 + | `setup.sh` | Generate secrets and write `pds.env` (called automatically by `start.sh`) | 24 + | `start.sh` | Start the PDS in the background with health checking | 25 + | `stop.sh` | Gracefully stop the PDS | 26 + | `reset.sh` | Stop and wipe all data for a fresh start | 27 + | `create-account.sh` | Create a test account (default: `test.localhost`) | 28 + | `login.sh` | Log in and print an access token | 29 + 30 + ## Notes 31 + 32 + - Data is stored in `scripts/nixtests/pds-data/` (gitignored) 33 + - PDS listens on port 3000 by default 34 + - Logs are written to `pds-data/pds.log`
+41
scripts/nixtests/create-account.sh
··· 1 + #!/usr/bin/env bash 2 + # Creates a test account on the local PDS. 3 + # 4 + # Usage: 5 + # ./create-account.sh # defaults: test.localhost / test-password-123 6 + # ./create-account.sh myhandle.localhost mypass 7 + set -euo pipefail 8 + 9 + HANDLE="${1:-test.localhost}" 10 + PASSWORD="${2:-test-password-123}" 11 + PDS_URL="${PDS_URL:-http://localhost:3000}" 12 + 13 + echo "Creating account: ${HANDLE}" 14 + 15 + RESPONSE=$(curl -s -X POST \ 16 + -H "Content-Type: application/json" \ 17 + -d "{ 18 + \"email\": \"${HANDLE}@example.com\", 19 + \"handle\": \"${HANDLE}\", 20 + \"password\": \"${PASSWORD}\" 21 + }" \ 22 + "${PDS_URL}/xrpc/com.atproto.server.createAccount") 23 + 24 + # check for error 25 + if echo "${RESPONSE}" | grep -q '"error"'; then 26 + echo "Failed to create account:" 27 + echo "${RESPONSE}" | python3 -m json.tool 2>/dev/null || echo "${RESPONSE}" 28 + exit 1 29 + fi 30 + 31 + DID=$(echo "${RESPONSE}" | python3 -c "import sys,json; print(json.load(sys.stdin)['did'])" 2>/dev/null || echo "unknown") 32 + ACCESS_JWT=$(echo "${RESPONSE}" | python3 -c "import sys,json; print(json.load(sys.stdin)['accessJwt'])" 2>/dev/null || echo "unknown") 33 + 34 + echo "" 35 + echo "Account created:" 36 + echo " Handle: ${HANDLE}" 37 + echo " Password: ${PASSWORD}" 38 + echo " DID: ${DID}" 39 + echo "" 40 + echo "Access token (for manual testing):" 41 + echo " ${ACCESS_JWT}"
+42
scripts/nixtests/login.sh
··· 1 + #!/usr/bin/env bash 2 + # Logs in to the local PDS and prints the access token. 3 + # 4 + # Usage: 5 + # ./login.sh # defaults: test.localhost / test-password-123 6 + # ./login.sh myhandle.localhost mypass 7 + # 8 + # The access token is printed on its own line for piping: 9 + # TOKEN=$(./login.sh) 10 + set -euo pipefail 11 + 12 + HANDLE="${1:-test.localhost}" 13 + PASSWORD="${2:-test-password-123}" 14 + PDS_URL="${PDS_URL:-http://localhost:3000}" 15 + 16 + RESPONSE=$(curl -s -X POST \ 17 + -H "Content-Type: application/json" \ 18 + -d "{ 19 + \"identifier\": \"${HANDLE}\", 20 + \"password\": \"${PASSWORD}\" 21 + }" \ 22 + "${PDS_URL}/xrpc/com.atproto.server.createSession") 23 + 24 + # check for error 25 + if echo "${RESPONSE}" | grep -q '"error"'; then 26 + echo "Login failed:" >&2 27 + echo "${RESPONSE}" | python3 -m json.tool 2>/dev/null >&2 || echo "${RESPONSE}" >&2 28 + exit 1 29 + fi 30 + 31 + # if stdout is a terminal, print labels; otherwise just the token 32 + if [ -t 1 ]; then 33 + DID=$(echo "${RESPONSE}" | python3 -c "import sys,json; print(json.load(sys.stdin)['did'])" 2>/dev/null || echo "unknown") 34 + ACCESS_JWT=$(echo "${RESPONSE}" | python3 -c "import sys,json; print(json.load(sys.stdin)['accessJwt'])" 2>/dev/null || echo "unknown") 35 + echo "Logged in as ${HANDLE} (${DID})" 36 + echo "" 37 + echo "Access token:" 38 + echo " ${ACCESS_JWT}" 39 + else 40 + # piped — just the token 41 + echo "${RESPONSE}" | python3 -c "import sys,json; print(json.load(sys.stdin)['accessJwt'])" 42 + fi
+12
scripts/nixtests/reset.sh
··· 1 + #!/usr/bin/env bash 2 + # Stops the PDS and wipes all data for a fresh start. 3 + set -euo pipefail 4 + 5 + SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" 6 + 7 + bash "${SCRIPT_DIR}/stop.sh" 8 + 9 + echo "Removing pds-data/ and pds.env..." 10 + rm -rf "${SCRIPT_DIR}/pds-data" 11 + rm -f "${SCRIPT_DIR}/pds.env" 12 + echo "Reset complete."
+47
scripts/nixtests/setup.sh
··· 1 + #!/usr/bin/env bash 2 + # Generates secrets and writes pds.env for local development. 3 + # Run once before starting the PDS for the first time. 4 + set -euo pipefail 5 + 6 + SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" 7 + ENV_FILE="${SCRIPT_DIR}/pds.env" 8 + DATA_DIR="${SCRIPT_DIR}/pds-data" 9 + 10 + if [ -f "${ENV_FILE}" ]; then 11 + echo "pds.env already exists, skipping setup." 12 + echo "Run reset.sh first if you want a fresh environment." 13 + exit 0 14 + fi 15 + 16 + echo "Generating secrets..." 17 + 18 + JWT_SECRET=$(openssl rand --hex 16) 19 + ADMIN_PASSWORD=$(openssl rand --hex 16) 20 + ROTATION_KEY=$(openssl ecparam -name secp256k1 -genkey -noout -outform DER 2>/dev/null | \ 21 + tail -c +8 | head -c 32 | xxd -p -c 32) 22 + 23 + mkdir -p "${DATA_DIR}" 24 + 25 + cat > "${ENV_FILE}" <<EOF 26 + PDS_HOSTNAME=localhost 27 + PDS_JWT_SECRET=${JWT_SECRET} 28 + PDS_ADMIN_PASSWORD=${ADMIN_PASSWORD} 29 + PDS_PLC_ROTATION_KEY_K256_PRIVATE_KEY_HEX=${ROTATION_KEY} 30 + PDS_DATA_DIRECTORY=${DATA_DIR} 31 + PDS_BLOBSTORE_DISK_LOCATION=${DATA_DIR}/blocks 32 + PDS_DID_PLC_URL=https://plc.directory 33 + PDS_BSKY_APP_VIEW_URL=https://api.bsky.app 34 + PDS_MOD_SERVICE_URL=https://mod.bsky.app 35 + PDS_REPORT_SERVICE_URL=https://mod.bsky.app 36 + PDS_CRAWLERS=https://bsky.network 37 + PDS_SERVICE_HANDLE_DOMAINS=.localhost 38 + PDS_DEV_MODE=true 39 + PDS_INVITE_REQUIRED=false 40 + PDS_PORT=3000 41 + LOG_LEVEL=info 42 + EOF 43 + 44 + echo "pds.env written." 45 + echo "" 46 + echo "Admin password: ${ADMIN_PASSWORD}" 47 + echo "Save this somewhere — you'll need it for admin operations."
+68
scripts/nixtests/start.sh
··· 1 + #!/usr/bin/env bash 2 + # Starts the PDS via the nix-provided `pds` binary. 3 + set -euo pipefail 4 + 5 + SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" 6 + DATA_DIR="${SCRIPT_DIR}/pds-data" 7 + PID_FILE="${DATA_DIR}/pds.pid" 8 + LOG_FILE="${DATA_DIR}/pds.log" 9 + ENV_FILE="${SCRIPT_DIR}/pds.env" 10 + 11 + # run setup if pds.env doesn't exist yet 12 + if [ ! -f "${ENV_FILE}" ]; then 13 + echo "No pds.env found, running setup.sh first..." 14 + bash "${SCRIPT_DIR}/setup.sh" 15 + fi 16 + 17 + # check for already-running process 18 + if [ -f "${PID_FILE}" ]; then 19 + OLD_PID=$(cat "${PID_FILE}") 20 + if kill -0 "${OLD_PID}" 2>/dev/null; then 21 + echo "PDS is already running (PID ${OLD_PID})." 22 + exit 0 23 + else 24 + echo "Stale PID file found, cleaning up." 25 + rm -f "${PID_FILE}" 26 + fi 27 + fi 28 + 29 + mkdir -p "${DATA_DIR}" 30 + 31 + # export env vars from pds.env 32 + set -a 33 + source "${ENV_FILE}" 34 + set +a 35 + 36 + echo "Starting PDS..." 37 + 38 + # start pds in background 39 + pds > "${LOG_FILE}" 2>&1 & 40 + PDS_PID=$! 41 + echo "${PDS_PID}" > "${PID_FILE}" 42 + 43 + echo "PDS started (PID ${PDS_PID}), waiting for health check..." 44 + 45 + # health-check loop: 30s timeout 46 + TIMEOUT=30 47 + ELAPSED=0 48 + while [ "${ELAPSED}" -lt "${TIMEOUT}" ]; do 49 + # make sure the process is still alive 50 + if ! kill -0 "${PDS_PID}" 2>/dev/null; then 51 + echo "PDS process died. Last log lines:" 52 + tail -20 "${LOG_FILE}" 53 + rm -f "${PID_FILE}" 54 + exit 1 55 + fi 56 + 57 + if curl -sf "http://localhost:${PDS_PORT:-3000}/xrpc/_health" > /dev/null 2>&1; then 58 + echo "PDS is healthy (http://localhost:${PDS_PORT:-3000})." 59 + exit 0 60 + fi 61 + 62 + sleep 1 63 + ELAPSED=$((ELAPSED + 1)) 64 + done 65 + 66 + echo "Health check timed out after ${TIMEOUT}s. Last log lines:" 67 + tail -20 "${LOG_FILE}" 68 + exit 1
+41
scripts/nixtests/stop.sh
··· 1 + #!/usr/bin/env bash 2 + # Stops the PDS via PID file. 3 + set -euo pipefail 4 + 5 + SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" 6 + PID_FILE="${SCRIPT_DIR}/pds-data/pds.pid" 7 + 8 + if [ ! -f "${PID_FILE}" ]; then 9 + echo "No PID file found — PDS is not running." 10 + exit 0 11 + fi 12 + 13 + PDS_PID=$(cat "${PID_FILE}") 14 + 15 + if ! kill -0 "${PDS_PID}" 2>/dev/null; then 16 + echo "Process ${PDS_PID} is not running. Cleaning up stale PID file." 17 + rm -f "${PID_FILE}" 18 + exit 0 19 + fi 20 + 21 + echo "Stopping PDS (PID ${PDS_PID})..." 22 + kill "${PDS_PID}" 23 + 24 + # wait up to 10s for graceful shutdown 25 + TIMEOUT=10 26 + ELAPSED=0 27 + while [ "${ELAPSED}" -lt "${TIMEOUT}" ]; do 28 + if ! kill -0 "${PDS_PID}" 2>/dev/null; then 29 + echo "PDS stopped." 30 + rm -f "${PID_FILE}" 31 + exit 0 32 + fi 33 + sleep 1 34 + ELAPSED=$((ELAPSED + 1)) 35 + done 36 + 37 + # force kill 38 + echo "Graceful shutdown timed out, sending SIGKILL..." 39 + kill -9 "${PDS_PID}" 2>/dev/null || true 40 + rm -f "${PID_FILE}" 41 + echo "PDS killed."
+47
scripts/pds-dev/README.md
··· 1 + # Local PDS for development 2 + 3 + Scripts for running an AT Protocol PDS server locally via Docker, used for integration testing of `pds-git-remote`. 4 + 5 + ## Quick start 6 + 7 + ```bash 8 + # start the PDS (generates secrets on first run) 9 + ./start.sh 10 + 11 + # create a test account 12 + ./create-account.sh 13 + 14 + # get an access token 15 + ./login.sh 16 + 17 + # or pipe the token directly 18 + TOKEN=$(./login.sh) 19 + ``` 20 + 21 + ## Scripts 22 + 23 + | Script | Purpose | 24 + |--------|---------| 25 + | `start.sh` | Start PDS (runs `setup.sh` automatically if needed) | 26 + | `stop.sh` | Stop PDS | 27 + | `reset.sh` | Stop PDS and wipe all data | 28 + | `setup.sh` | Generate secrets and write `pds.env` (called by `start.sh`) | 29 + | `create-account.sh [handle] [password]` | Create a test account | 30 + | `login.sh [handle] [password]` | Get an access token | 31 + 32 + ## Defaults 33 + 34 + - PDS URL: `http://localhost:3000` 35 + - Test account: `test.localhost` / `test-password-123` 36 + - Override PDS URL with `PDS_URL` env var 37 + 38 + ## Health check 39 + 40 + ```bash 41 + curl http://localhost:3000/xrpc/_health 42 + ``` 43 + 44 + ## Files (gitignored) 45 + 46 + - `pds.env` — generated secrets 47 + - `pds-data/` — PDS data directory (SQLite, blobs)
+14
scripts/pds-dev/compose.yaml
··· 1 + services: 2 + pds: 3 + container_name: pds 4 + image: ghcr.io/bluesky-social/pds:0.4 5 + ports: 6 + - "3000:3000" 7 + restart: unless-stopped 8 + volumes: 9 + - pds-data:/pds 10 + env_file: 11 + - pds.env 12 + 13 + volumes: 14 + pds-data:
+41
scripts/pds-dev/create-account.sh
··· 1 + #!/usr/bin/env bash 2 + # Creates a test account on the local PDS. 3 + # 4 + # Usage: 5 + # ./create-account.sh # defaults: test.localhost / test-password-123 6 + # ./create-account.sh myhandle.localhost mypass 7 + set -euo pipefail 8 + 9 + HANDLE="${1:-alice.test}" 10 + PASSWORD="${2:-test-password-123}" 11 + PDS_URL="${PDS_URL:-http://localhost:3000}" 12 + 13 + echo "Creating account: ${HANDLE}" 14 + 15 + RESPONSE=$(curl -s -X POST \ 16 + -H "Content-Type: application/json" \ 17 + -d "{ 18 + \"email\": \"${HANDLE}@example.com\", 19 + \"handle\": \"${HANDLE}\", 20 + \"password\": \"${PASSWORD}\" 21 + }" \ 22 + "${PDS_URL}/xrpc/com.atproto.server.createAccount") 23 + 24 + # check for error 25 + if echo "${RESPONSE}" | grep -q '"error"'; then 26 + echo "Failed to create account:" 27 + echo "${RESPONSE}" | python3 -m json.tool 2>/dev/null || echo "${RESPONSE}" 28 + exit 1 29 + fi 30 + 31 + DID=$(echo "${RESPONSE}" | python3 -c "import sys,json; print(json.load(sys.stdin)['did'])" 2>/dev/null || echo "unknown") 32 + ACCESS_JWT=$(echo "${RESPONSE}" | python3 -c "import sys,json; print(json.load(sys.stdin)['accessJwt'])" 2>/dev/null || echo "unknown") 33 + 34 + echo "" 35 + echo "Account created:" 36 + echo " Handle: ${HANDLE}" 37 + echo " Password: ${PASSWORD}" 38 + echo " DID: ${DID}" 39 + echo "" 40 + echo "Access token (for manual testing):" 41 + echo " ${ACCESS_JWT}"
+42
scripts/pds-dev/login.sh
··· 1 + #!/usr/bin/env bash 2 + # Logs in to the local PDS and prints the access token. 3 + # 4 + # Usage: 5 + # ./login.sh # defaults: test.localhost / test-password-123 6 + # ./login.sh myhandle.localhost mypass 7 + # 8 + # The access token is printed on its own line for piping: 9 + # TOKEN=$(./login.sh) 10 + set -euo pipefail 11 + 12 + HANDLE="${1:-alice.test}" 13 + PASSWORD="${2:-test-password-123}" 14 + PDS_URL="${PDS_URL:-http://localhost:3000}" 15 + 16 + RESPONSE=$(curl -s -X POST \ 17 + -H "Content-Type: application/json" \ 18 + -d "{ 19 + \"identifier\": \"${HANDLE}\", 20 + \"password\": \"${PASSWORD}\" 21 + }" \ 22 + "${PDS_URL}/xrpc/com.atproto.server.createSession") 23 + 24 + # check for error 25 + if echo "${RESPONSE}" | grep -q '"error"'; then 26 + echo "Login failed:" >&2 27 + echo "${RESPONSE}" | python3 -m json.tool 2>/dev/null >&2 || echo "${RESPONSE}" >&2 28 + exit 1 29 + fi 30 + 31 + # if stdout is a terminal, print labels; otherwise just the token 32 + if [ -t 1 ]; then 33 + DID=$(echo "${RESPONSE}" | python3 -c "import sys,json; print(json.load(sys.stdin)['did'])" 2>/dev/null || echo "unknown") 34 + ACCESS_JWT=$(echo "${RESPONSE}" | python3 -c "import sys,json; print(json.load(sys.stdin)['accessJwt'])" 2>/dev/null || echo "unknown") 35 + echo "Logged in as ${HANDLE} (${DID})" 36 + echo "" 37 + echo "Access token:" 38 + echo " ${ACCESS_JWT}" 39 + else 40 + # piped — just print the token 41 + echo "${RESPONSE}" | python3 -c "import sys,json; print(json.load(sys.stdin)['accessJwt'])" 42 + fi
+12
scripts/pds-dev/reset.sh
··· 1 + #!/usr/bin/env bash 2 + # Stops the PDS and wipes all data (accounts, blobs, records). 3 + # You'll need to run start.sh and create-account.sh again afterward. 4 + set -euo pipefail 5 + 6 + SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" 7 + 8 + echo "Stopping PDS and wiping all data..." 9 + docker compose -f "${SCRIPT_DIR}/compose.yaml" down -v 2>/dev/null || true 10 + rm -rf "${SCRIPT_DIR}/pds-data" 11 + rm -f "${SCRIPT_DIR}/pds.env" 12 + echo "Done. Run start.sh to set up a fresh PDS."
+52
scripts/pds-dev/run-e2e.sh
··· 1 + #!/usr/bin/env bash 2 + # Runs E2E tests against a fresh local PDS. 3 + # 4 + # Usage: 5 + # ./run-e2e.sh # start PDS, run tests, stop PDS 6 + # ./run-e2e.sh --keep # keep PDS running after tests 7 + set -euo pipefail 8 + 9 + SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" 10 + CRATE_DIR="$(cd "${SCRIPT_DIR}/../.." && pwd)" 11 + KEEP=false 12 + 13 + for arg in "$@"; do 14 + if [ "$arg" = "--keep" ]; then 15 + KEEP=true 16 + fi 17 + done 18 + 19 + # clean up PDS on exit unless --keep 20 + cleanup() { 21 + if [ "$KEEP" = false ]; then 22 + echo "" 23 + echo "Stopping PDS..." 24 + bash "${SCRIPT_DIR}/stop.sh" 25 + else 26 + echo "" 27 + echo "PDS left running (--keep). Stop with: bash ${SCRIPT_DIR}/stop.sh" 28 + fi 29 + } 30 + trap cleanup EXIT 31 + 32 + # reset to clean slate 33 + echo "=== Resetting PDS ===" 34 + bash "${SCRIPT_DIR}/reset.sh" 35 + 36 + # start PDS 37 + echo "" 38 + echo "=== Starting PDS ===" 39 + bash "${SCRIPT_DIR}/start.sh" 40 + 41 + # create test account 42 + echo "" 43 + echo "=== Creating test account ===" 44 + bash "${SCRIPT_DIR}/create-account.sh" 45 + 46 + # run E2E tests 47 + echo "" 48 + echo "=== Running E2E tests ===" 49 + cargo test -p pds-git-remote --features e2e -- --test-threads=1 50 + 51 + echo "" 52 + echo "=== All E2E tests passed ==="
+49
scripts/pds-dev/setup.sh
··· 1 + #!/usr/bin/env bash 2 + # Generates secrets and writes pds.env for local development. 3 + # Run once before starting the PDS for the first time. 4 + set -euo pipefail 5 + 6 + SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" 7 + ENV_FILE="${SCRIPT_DIR}/pds.env" 8 + DATA_DIR="${SCRIPT_DIR}/pds-data" 9 + 10 + if [ -f "${ENV_FILE}" ]; then 11 + echo "pds.env already exists, skipping setup." 12 + echo "Run reset.sh first if you want a fresh environment." 13 + exit 0 14 + fi 15 + 16 + echo "Generating secrets..." 17 + 18 + JWT_SECRET=$(openssl rand --hex 16) 19 + ADMIN_PASSWORD=$(openssl rand --hex 16) 20 + ROTATION_KEY=$(openssl ecparam -name secp256k1 -genkey -noout -outform DER 2>/dev/null | \ 21 + tail -c +8 | head -c 32 | xxd -p -c 32) 22 + 23 + mkdir -p "${DATA_DIR}" 24 + 25 + cat > "${ENV_FILE}" <<EOF 26 + PDS_HOSTNAME=pds.test 27 + PDS_JWT_SECRET=${JWT_SECRET} 28 + PDS_ADMIN_PASSWORD=${ADMIN_PASSWORD} 29 + PDS_PLC_ROTATION_KEY_K256_PRIVATE_KEY_HEX=${ROTATION_KEY} 30 + PDS_DATA_DIRECTORY=/pds 31 + PDS_BLOBSTORE_DISK_LOCATION=/pds/blocks 32 + PDS_DID_PLC_URL=https://plc.directory 33 + PDS_BSKY_APP_VIEW_URL=https://api.bsky.app 34 + PDS_BSKY_APP_VIEW_DID=did:web:api.bsky.app 35 + PDS_MOD_SERVICE_URL=https://mod.bsky.app 36 + PDS_MOD_SERVICE_DID=did:plc:ar7c4by46qjdydhdevvrndac 37 + PDS_REPORT_SERVICE_URL=https://mod.bsky.app 38 + PDS_REPORT_SERVICE_DID=did:plc:ar7c4by46qjdydhdevvrndac 39 + PDS_CRAWLERS=https://bsky.network 40 + PDS_SERVICE_HANDLE_DOMAINS=.test 41 + PDS_DEV_MODE=true 42 + PDS_INVITE_REQUIRED=false 43 + LOG_LEVEL=info 44 + EOF 45 + 46 + echo "pds.env written." 47 + echo "" 48 + echo "Admin password: ${ADMIN_PASSWORD}" 49 + echo "Save this somewhere — you'll need it for admin operations."
+30
scripts/pds-dev/start.sh
··· 1 + #!/usr/bin/env bash 2 + # Starts the local PDS server via Docker Compose. 3 + # Runs setup.sh automatically if pds.env doesn't exist yet. 4 + set -euo pipefail 5 + 6 + SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" 7 + 8 + # run setup if needed 9 + if [ ! -f "${SCRIPT_DIR}/pds.env" ]; then 10 + echo "No pds.env found, running setup..." 11 + bash "${SCRIPT_DIR}/setup.sh" 12 + echo "" 13 + fi 14 + 15 + echo "Starting PDS..." 16 + docker compose -f "${SCRIPT_DIR}/compose.yaml" up -d 17 + 18 + # wait for health check 19 + echo "Waiting for PDS to be ready..." 20 + for i in $(seq 1 30); do 21 + if curl -sf http://localhost:3000/xrpc/_health > /dev/null 2>&1; then 22 + echo "PDS is ready at http://localhost:3000" 23 + exit 0 24 + fi 25 + sleep 1 26 + done 27 + 28 + echo "ERROR: PDS did not become healthy within 30 seconds." 29 + echo "Check logs with: docker compose -f ${SCRIPT_DIR}/compose.yaml logs" 30 + exit 1
+7
scripts/pds-dev/stop.sh
··· 1 + #!/usr/bin/env bash 2 + # Stops the local PDS server. 3 + set -euo pipefail 4 + 5 + SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" 6 + docker compose -f "${SCRIPT_DIR}/compose.yaml" down 7 + echo "PDS stopped."
+195
src/auth.rs
··· 1 + //! Credential storage and authentication resolution. 2 + //! 3 + //! Stores AT Protocol credentials at `~/.config/pds-git-remote/auth.json`. 4 + //! Provides `resolve_auth` for the remote helper to find credentials from 5 + //! env vars or stored config. 6 + 7 + use std::collections::HashMap; 8 + use std::path::PathBuf; 9 + 10 + use serde::{Deserialize, Serialize}; 11 + 12 + use crate::pds_client::PdsClient; 13 + 14 + /// Stored credential for a single AT Protocol account. 15 + #[derive(Debug, Clone, Serialize, Deserialize)] 16 + pub struct StoredCredential { 17 + pub pds_url: String, 18 + pub handle: String, 19 + pub did: String, 20 + pub access_jwt: String, 21 + pub refresh_jwt: String, 22 + } 23 + 24 + /// Top-level auth config file. 25 + #[derive(Debug, Clone, Serialize, Deserialize, Default)] 26 + pub struct AuthConfig { 27 + #[serde(default)] 28 + pub credentials: HashMap<String, StoredCredential>, 29 + } 30 + 31 + /// Resolved auth info needed for push operations. 32 + #[derive(Debug, Clone)] 33 + pub struct ResolvedAuth { 34 + pub pds_url: String, 35 + pub did: String, 36 + pub access_jwt: String, 37 + } 38 + 39 + /// Returns the path to the auth config file. 40 + fn config_path() -> PathBuf { 41 + let home = std::env::var("HOME").unwrap_or_else(|_| ".".to_string()); 42 + PathBuf::from(home) 43 + .join(".config") 44 + .join("pds-git-remote") 45 + .join("auth.json") 46 + } 47 + 48 + /// Loads the auth config from disk, returning a default if the file is missing. 49 + pub fn load_config() -> Result<AuthConfig, String> { 50 + let path = config_path(); 51 + if !path.exists() { 52 + return Ok(AuthConfig::default()); 53 + } 54 + 55 + let data = 56 + std::fs::read_to_string(&path).map_err(|e| format!("failed to read auth config: {}", e))?; 57 + 58 + serde_json::from_str(&data).map_err(|e| format!("failed to parse auth config: {}", e)) 59 + } 60 + 61 + /// Saves the auth config to disk, creating parent directories if needed. 62 + pub fn save_config(config: &AuthConfig) -> Result<(), String> { 63 + let path = config_path(); 64 + 65 + if let Some(parent) = path.parent() { 66 + std::fs::create_dir_all(parent) 67 + .map_err(|e| format!("failed to create config directory: {}", e))?; 68 + } 69 + 70 + let data = serde_json::to_string_pretty(config) 71 + .map_err(|e| format!("failed to serialize auth config: {}", e))?; 72 + 73 + std::fs::write(&path, data).map_err(|e| format!("failed to write auth config: {}", e)) 74 + } 75 + 76 + /// Looks up a stored credential by handle. 77 + pub fn get_credential(handle: &str) -> Result<Option<StoredCredential>, String> { 78 + let config = load_config()?; 79 + Ok(config.credentials.get(handle).cloned()) 80 + } 81 + 82 + /// Logs in via createSession and stores the credential. 83 + pub async fn login_and_store( 84 + pds_url: &str, 85 + handle: &str, 86 + password: &str, 87 + ) -> Result<StoredCredential, String> { 88 + let mut client = PdsClient::new(pds_url); 89 + let session = client.login(handle, password).await?; 90 + 91 + let cred = StoredCredential { 92 + pds_url: pds_url.to_string(), 93 + handle: session.handle.clone(), 94 + did: session.did.clone(), 95 + access_jwt: session.access_jwt, 96 + refresh_jwt: session.refresh_jwt, 97 + }; 98 + 99 + // save to config 100 + let mut config = load_config()?; 101 + config.credentials.insert(handle.to_string(), cred.clone()); 102 + save_config(&config)?; 103 + 104 + Ok(cred) 105 + } 106 + 107 + /// Resolves authentication for a push operation. 108 + /// 109 + /// Checks sources in priority order: 110 + /// 1. `PDS_ACCESS_TOKEN` + `PDS_DID` env vars (direct, no network) 111 + /// 2. `PDS_HANDLE` + `PDS_PASSWORD` env vars (login on the fly) 112 + /// 3. Stored credential from auth.json 113 + /// 4. Error: not logged in 114 + pub async fn resolve_auth(handle: &str, pds_url: &str) -> Result<ResolvedAuth, String> { 115 + // 1. direct token from env 116 + if let (Ok(token), Ok(did)) = (std::env::var("PDS_ACCESS_TOKEN"), std::env::var("PDS_DID")) { 117 + return Ok(ResolvedAuth { 118 + pds_url: pds_url.to_string(), 119 + did, 120 + access_jwt: token, 121 + }); 122 + } 123 + 124 + // 2. handle + password from env → login on the fly 125 + if let (Ok(env_handle), Ok(password)) = 126 + (std::env::var("PDS_HANDLE"), std::env::var("PDS_PASSWORD")) 127 + { 128 + let cred = login_and_store(pds_url, &env_handle, &password).await?; 129 + return Ok(ResolvedAuth { 130 + pds_url: cred.pds_url, 131 + did: cred.did, 132 + access_jwt: cred.access_jwt, 133 + }); 134 + } 135 + 136 + // 3. stored credential 137 + if let Some(cred) = get_credential(handle)? { 138 + return Ok(ResolvedAuth { 139 + pds_url: cred.pds_url, 140 + did: cred.did, 141 + access_jwt: cred.access_jwt, 142 + }); 143 + } 144 + 145 + // 4. no credentials found 146 + Err(format!( 147 + "not logged in for '{}' — run: git-remote-pds auth login --handle {}", 148 + handle, handle 149 + )) 150 + } 151 + 152 + /// Removes a stored credential by handle. 153 + pub fn logout(handle: &str) -> Result<bool, String> { 154 + let mut config = load_config()?; 155 + let removed = config.credentials.remove(handle).is_some(); 156 + if removed { 157 + save_config(&config)?; 158 + } 159 + Ok(removed) 160 + } 161 + 162 + #[cfg(test)] 163 + mod tests { 164 + use super::*; 165 + 166 + #[test] 167 + fn auth_config_round_trip() { 168 + let mut config = AuthConfig::default(); 169 + config.credentials.insert( 170 + "alice.test".to_string(), 171 + StoredCredential { 172 + pds_url: "http://localhost:3000".to_string(), 173 + handle: "alice.test".to_string(), 174 + did: "did:plc:abc123".to_string(), 175 + access_jwt: "jwt-access".to_string(), 176 + refresh_jwt: "jwt-refresh".to_string(), 177 + }, 178 + ); 179 + 180 + let json = serde_json::to_string(&config).unwrap(); 181 + let parsed: AuthConfig = serde_json::from_str(&json).unwrap(); 182 + 183 + assert_eq!(parsed.credentials.len(), 1); 184 + let cred = parsed.credentials.get("alice.test").unwrap(); 185 + assert_eq!(cred.did, "did:plc:abc123"); 186 + assert_eq!(cred.handle, "alice.test"); 187 + } 188 + 189 + #[test] 190 + fn empty_config_deserializes() { 191 + let json = "{}"; 192 + let config: AuthConfig = serde_json::from_str(json).unwrap(); 193 + assert!(config.credentials.is_empty()); 194 + } 195 + }
+315
src/fetch.rs
··· 1 + //! Fetch/clone flow: download bundle chain from PDS and apply to local repo. 2 + //! 3 + //! Reads the remote state record, downloads bundles as blobs, 4 + //! reassembles chunked bundles, applies them via `git bundle unbundle`, 5 + //! and updates local refs to match the remote state. 6 + 7 + use std::path::Path; 8 + 9 + use crate::bundle::apply_bundle; 10 + use crate::chunk::reassemble_chunks; 11 + use crate::pds_client::PdsClient; 12 + use crate::types::{BundleEntry, COLLECTION, RepoState}; 13 + 14 + /// Result of a fetch or clone operation. 15 + #[derive(Debug)] 16 + pub enum FetchResult { 17 + /// bundles were downloaded and applied 18 + Applied { 19 + /// number of bundles applied 20 + bundles_applied: usize, 21 + /// total bytes downloaded 22 + bytes_downloaded: u64, 23 + }, 24 + /// local refs already match remote — nothing to do 25 + AlreadyUpToDate, 26 + } 27 + 28 + /// Clones a repository from PDS into a local git repo. 29 + /// 30 + /// The repo at `repo_path` should already be initialized (bare or working). 31 + /// Downloads all bundles in the chain from oldest to newest, applies each, 32 + /// then updates local refs and checks out the default branch (if working tree). 33 + pub async fn clone_repo( 34 + client: &PdsClient, 35 + did: &str, 36 + repo_name: &str, 37 + repo_path: &Path, 38 + ) -> Result<FetchResult, String> { 39 + // read remote state 40 + let state = read_remote_state(client, did, repo_name).await?; 41 + 42 + if state.bundles.is_empty() { 43 + return Err("remote state has no bundles".to_string()); 44 + } 45 + 46 + // download and apply all bundles in order (oldest first) 47 + let (applied, downloaded) = download_and_apply(client, did, repo_path, &state.bundles).await?; 48 + 49 + // update local refs to match remote state 50 + update_local_refs(repo_path, &state).await?; 51 + 52 + // checkout default branch if this is a working tree 53 + checkout_default_branch(repo_path, &state).await?; 54 + 55 + Ok(FetchResult::Applied { 56 + bundles_applied: applied, 57 + bytes_downloaded: downloaded, 58 + }) 59 + } 60 + 61 + /// Fetches new commits from PDS into an existing local repo. 62 + /// 63 + /// Compares local refs against the remote state and downloads only 64 + /// bundles that contain new commits. Skips bundles whose tips are 65 + /// already present in the local repo. 66 + pub async fn fetch_repo( 67 + client: &PdsClient, 68 + did: &str, 69 + repo_name: &str, 70 + repo_path: &Path, 71 + ) -> Result<FetchResult, String> { 72 + // read remote state 73 + let state = read_remote_state(client, did, repo_name).await?; 74 + 75 + // find which bundles we still need 76 + let new_bundles = find_new_bundles(repo_path, &state.bundles).await?; 77 + 78 + if new_bundles.is_empty() { 79 + return Ok(FetchResult::AlreadyUpToDate); 80 + } 81 + 82 + // download and apply only the new bundles 83 + let (applied, downloaded) = download_and_apply(client, did, repo_path, &new_bundles).await?; 84 + 85 + // update local refs to match remote state 86 + update_local_refs(repo_path, &state).await?; 87 + 88 + // update the working tree if not a bare repo 89 + update_working_tree(repo_path).await?; 90 + 91 + Ok(FetchResult::Applied { 92 + bundles_applied: applied, 93 + bytes_downloaded: downloaded, 94 + }) 95 + } 96 + 97 + /// Reads and parses the remote state record from PDS. 98 + pub async fn read_remote_state( 99 + client: &PdsClient, 100 + did: &str, 101 + repo_name: &str, 102 + ) -> Result<RepoState, String> { 103 + let record = client 104 + .get_record(did, COLLECTION, repo_name) 105 + .await? 106 + .ok_or_else(|| format!("no state record found for {}", repo_name))?; 107 + 108 + serde_json::from_value(record.value).map_err(|e| format!("failed to parse remote state: {}", e)) 109 + } 110 + 111 + /// Downloads and applies a slice of bundle entries to the local repo. 112 + /// 113 + /// Returns (bundles_applied, total_bytes_downloaded). 114 + pub async fn download_and_apply( 115 + client: &PdsClient, 116 + did: &str, 117 + repo_path: &Path, 118 + bundles: &[BundleEntry], 119 + ) -> Result<(usize, u64), String> { 120 + let mut total_bytes: u64 = 0; 121 + 122 + for (i, entry) in bundles.iter().enumerate() { 123 + tracing::info!( 124 + "downloading bundle {}/{} ({} part(s))", 125 + i + 1, 126 + bundles.len(), 127 + entry.parts.len() 128 + ); 129 + 130 + // download all parts of this bundle 131 + let bundle_data = download_bundle_parts(client, did, entry).await?; 132 + total_bytes += bundle_data.len() as u64; 133 + 134 + // apply the bundle 135 + apply_bundle(repo_path, &bundle_data).await?; 136 + } 137 + 138 + Ok((bundles.len(), total_bytes)) 139 + } 140 + 141 + /// Downloads and reassembles the parts of a single bundle entry. 142 + /// 143 + /// Most bundles have a single part. Chunked bundles (>40MB) have 144 + /// multiple parts that are concatenated in order. 145 + async fn download_bundle_parts( 146 + client: &PdsClient, 147 + did: &str, 148 + entry: &BundleEntry, 149 + ) -> Result<Vec<u8>, String> { 150 + if entry.parts.len() == 1 { 151 + // common case: single-part bundle 152 + return client.get_blob(did, entry.parts[0].cid()).await; 153 + } 154 + 155 + // multi-part: download each chunk and reassemble 156 + let mut parts = Vec::with_capacity(entry.parts.len()); 157 + for part in &entry.parts { 158 + let data = client.get_blob(did, part.cid()).await?; 159 + parts.push(data); 160 + } 161 + 162 + Ok(reassemble_chunks(&parts)) 163 + } 164 + 165 + /// Determines which bundles the local repo doesn't have yet. 166 + /// 167 + /// Walks the bundle chain from oldest to newest and skips entries 168 + /// whose tip commits are already present in the local repo. 169 + pub async fn find_new_bundles<'a>( 170 + repo_path: &Path, 171 + bundles: &'a [BundleEntry], 172 + ) -> Result<Vec<BundleEntry>, String> { 173 + let mut new_bundles = Vec::new(); 174 + 175 + for entry in bundles { 176 + // a bundle is "already applied" if all its tips exist locally 177 + let all_tips_present = if entry.tips.is_empty() { 178 + false 179 + } else { 180 + let mut all_present = true; 181 + for tip in &entry.tips { 182 + if !commit_exists(repo_path, tip).await? { 183 + all_present = false; 184 + break; 185 + } 186 + } 187 + all_present 188 + }; 189 + 190 + if !all_tips_present { 191 + new_bundles.push(entry.clone()); 192 + } 193 + } 194 + 195 + Ok(new_bundles) 196 + } 197 + 198 + /// Checks if a commit SHA exists in the local repository. 199 + async fn commit_exists(repo_path: &Path, sha: &str) -> Result<bool, String> { 200 + let output = tokio::process::Command::new("git") 201 + .args(["cat-file", "-t", sha]) 202 + .current_dir(repo_path) 203 + .output() 204 + .await 205 + .map_err(|e| format!("failed to run git cat-file: {}", e))?; 206 + 207 + Ok(output.status.success()) 208 + } 209 + 210 + /// Updates local refs to match the remote state record. 211 + /// 212 + /// Uses `git update-ref` for each ref in the state. 213 + async fn update_local_refs(repo_path: &Path, state: &RepoState) -> Result<(), String> { 214 + for git_ref in &state.refs { 215 + let output = tokio::process::Command::new("git") 216 + .args(["update-ref", &git_ref.name, &git_ref.sha]) 217 + .current_dir(repo_path) 218 + .output() 219 + .await 220 + .map_err(|e| format!("failed to run git update-ref: {}", e))?; 221 + 222 + if !output.status.success() { 223 + let stderr = String::from_utf8_lossy(&output.stderr); 224 + return Err(format!( 225 + "git update-ref {} {} failed: {}", 226 + git_ref.name, 227 + git_ref.sha, 228 + stderr.trim() 229 + )); 230 + } 231 + } 232 + 233 + Ok(()) 234 + } 235 + 236 + /// Checks out the default branch if the repo has a working tree. 237 + /// 238 + /// Picks the first ref that looks like a main branch (main, master), 239 + /// or falls back to the first ref in the state. 240 + async fn checkout_default_branch(repo_path: &Path, state: &RepoState) -> Result<(), String> { 241 + // skip checkout for bare repos 242 + if is_bare_repo(repo_path).await? { 243 + return Ok(()); 244 + } 245 + 246 + if state.refs.is_empty() { 247 + return Ok(()); 248 + } 249 + 250 + // find the best default branch 251 + let default_ref = state 252 + .refs 253 + .iter() 254 + .find(|r| r.name == "refs/heads/main") 255 + .or_else(|| state.refs.iter().find(|r| r.name == "refs/heads/master")) 256 + .unwrap_or(&state.refs[0]); 257 + 258 + // extract branch name from full ref 259 + let branch = default_ref 260 + .name 261 + .strip_prefix("refs/heads/") 262 + .unwrap_or(&default_ref.name); 263 + 264 + let output = tokio::process::Command::new("git") 265 + .args(["checkout", branch]) 266 + .current_dir(repo_path) 267 + .output() 268 + .await 269 + .map_err(|e| format!("failed to run git checkout: {}", e))?; 270 + 271 + if !output.status.success() { 272 + let stderr = String::from_utf8_lossy(&output.stderr); 273 + return Err(format!("git checkout {} failed: {}", branch, stderr.trim())); 274 + } 275 + 276 + Ok(()) 277 + } 278 + 279 + /// Updates the working tree to match the current branch HEAD. 280 + /// 281 + /// After `update_local_refs` moves branch pointers forward, the working 282 + /// tree is stale. This resets it to match. Skips bare repos. 283 + async fn update_working_tree(repo_path: &Path) -> Result<(), String> { 284 + if is_bare_repo(repo_path).await? { 285 + return Ok(()); 286 + } 287 + 288 + // reset the working tree to match the updated branch tip 289 + let output = tokio::process::Command::new("git") 290 + .args(["reset", "--hard", "HEAD"]) 291 + .current_dir(repo_path) 292 + .output() 293 + .await 294 + .map_err(|e| format!("failed to run git reset: {}", e))?; 295 + 296 + if !output.status.success() { 297 + let stderr = String::from_utf8_lossy(&output.stderr); 298 + return Err(format!("git reset --hard HEAD failed: {}", stderr.trim())); 299 + } 300 + 301 + Ok(()) 302 + } 303 + 304 + /// Returns true if the repo at the given path is a bare repository. 305 + async fn is_bare_repo(repo_path: &Path) -> Result<bool, String> { 306 + let output = tokio::process::Command::new("git") 307 + .args(["rev-parse", "--is-bare-repository"]) 308 + .current_dir(repo_path) 309 + .output() 310 + .await 311 + .map_err(|e| format!("failed to run git rev-parse --is-bare-repository: {}", e))?; 312 + 313 + let stdout = String::from_utf8_lossy(&output.stdout); 314 + Ok(stdout.trim() == "true") 315 + }
+3
src/lib.rs
··· 4 4 //! as chains of incremental git bundles uploaded as PDS blobs, tracked 5 5 //! by a single mutable state record. 6 6 7 + pub mod auth; 7 8 pub mod bundle; 8 9 pub mod chunk; 10 + pub mod fetch; 9 11 pub mod identity; 10 12 pub mod pds_client; 13 + pub mod push; 11 14 pub mod types;
+152
src/main.rs
··· 1 + //! git-remote-pds: PDS-backed git remote helper. 2 + //! 3 + //! When invoked by git as a remote helper (3 args, not "auth"): 4 + //! git-remote-pds <remote-name> <url> 5 + //! 6 + //! When invoked directly for credential management: 7 + //! git-remote-pds auth login --pds-url <url> --handle <handle> 8 + //! git-remote-pds auth status 9 + //! git-remote-pds auth logout --handle <handle> 10 + 11 + mod remote_helper; 12 + 13 + use clap::{Parser, Subcommand}; 14 + 15 + #[derive(Parser)] 16 + #[command(name = "git-remote-pds", about = "PDS-backed git remote helper")] 17 + struct Cli { 18 + #[command(subcommand)] 19 + command: CliCommand, 20 + } 21 + 22 + #[derive(Subcommand)] 23 + enum CliCommand { 24 + /// Manage authentication credentials 25 + Auth { 26 + #[command(subcommand)] 27 + action: AuthAction, 28 + }, 29 + } 30 + 31 + #[derive(Subcommand)] 32 + enum AuthAction { 33 + /// Log in to a PDS and store credentials 34 + Login { 35 + /// PDS server URL 36 + #[arg(long)] 37 + pds_url: String, 38 + /// AT Protocol handle 39 + #[arg(long)] 40 + handle: String, 41 + }, 42 + /// Show stored credentials 43 + Status, 44 + /// Remove stored credentials for a handle 45 + Logout { 46 + /// AT Protocol handle to log out 47 + #[arg(long)] 48 + handle: String, 49 + }, 50 + } 51 + 52 + #[tokio::main] 53 + async fn main() { 54 + // initialize logging to stderr (stdout is reserved for protocol) 55 + tracing_subscriber::fmt() 56 + .with_writer(std::io::stderr) 57 + .with_env_filter(tracing_subscriber::EnvFilter::from_default_env()) 58 + .init(); 59 + 60 + let args: Vec<String> = std::env::args().collect(); 61 + 62 + // detect remote helper mode: git invokes us as `git-remote-pds <name> <url>` 63 + // with exactly 3 args and the second arg is not "auth" 64 + if args.len() == 3 && args[1] != "auth" { 65 + let remote_name = &args[1]; 66 + let url = &args[2]; 67 + 68 + if let Err(e) = remote_helper::run(remote_name, url).await { 69 + eprintln!("git-remote-pds: error: {}", e); 70 + std::process::exit(1); 71 + } 72 + return; 73 + } 74 + 75 + // CLI mode — parse with clap 76 + let cli = Cli::parse(); 77 + 78 + match cli.command { 79 + CliCommand::Auth { action } => match action { 80 + AuthAction::Login { pds_url, handle } => { 81 + handle_login(&pds_url, &handle).await; 82 + } 83 + AuthAction::Status => { 84 + handle_status(); 85 + } 86 + AuthAction::Logout { handle } => { 87 + handle_logout(&handle); 88 + } 89 + }, 90 + } 91 + } 92 + 93 + /// Handles `auth login` — reads password from env or stdin, logs in, stores credential. 94 + async fn handle_login(pds_url: &str, handle: &str) { 95 + // read password from PDS_PASSWORD env var or stdin 96 + let password = match std::env::var("PDS_PASSWORD") { 97 + Ok(pw) => pw, 98 + Err(_) => { 99 + eprint!("Password: "); 100 + let mut pw = String::new(); 101 + std::io::stdin() 102 + .read_line(&mut pw) 103 + .expect("failed to read password"); 104 + pw.trim().to_string() 105 + } 106 + }; 107 + 108 + match pds_git_remote::auth::login_and_store(pds_url, handle, &password).await { 109 + Ok(cred) => { 110 + eprintln!("logged in as {} ({})", cred.handle, cred.did); 111 + } 112 + Err(e) => { 113 + eprintln!("login failed: {}", e); 114 + std::process::exit(1); 115 + } 116 + } 117 + } 118 + 119 + /// Handles `auth status` — prints stored credentials. 120 + fn handle_status() { 121 + match pds_git_remote::auth::load_config() { 122 + Ok(config) => { 123 + if config.credentials.is_empty() { 124 + eprintln!("no stored credentials"); 125 + return; 126 + } 127 + for (handle, cred) in &config.credentials { 128 + eprintln!("{} ({}) @ {}", handle, cred.did, cred.pds_url); 129 + } 130 + } 131 + Err(e) => { 132 + eprintln!("failed to load config: {}", e); 133 + std::process::exit(1); 134 + } 135 + } 136 + } 137 + 138 + /// Handles `auth logout` — removes stored credential. 139 + fn handle_logout(handle: &str) { 140 + match pds_git_remote::auth::logout(handle) { 141 + Ok(true) => { 142 + eprintln!("logged out {}", handle); 143 + } 144 + Ok(false) => { 145 + eprintln!("no stored credential for {}", handle); 146 + } 147 + Err(e) => { 148 + eprintln!("logout failed: {}", e); 149 + std::process::exit(1); 150 + } 151 + } 152 + }
+287
src/push.rs
··· 1 + //! Push flow: upload local commits to PDS as incremental bundles. 2 + //! 3 + //! Reads the current remote state, creates a bundle of new commits, 4 + //! uploads it as blob(s), and updates the state record. 5 + 6 + use std::path::Path; 7 + 8 + use crate::bundle::{create_full_bundle, create_incremental_bundle}; 9 + use crate::chunk::{DEFAULT_CHUNK_SIZE, chunk_bytes}; 10 + use crate::pds_client::PdsClient; 11 + use crate::types::{BundleEntry, COLLECTION, GitRef, RepoState}; 12 + 13 + /// Result of a push operation. 14 + #[derive(Debug)] 15 + pub enum PushResult { 16 + /// new commits were pushed successfully 17 + Pushed { 18 + /// number of bundles uploaded (usually 1) 19 + bundles_uploaded: usize, 20 + /// total bytes uploaded 21 + bytes_uploaded: u64, 22 + }, 23 + /// local and remote refs match — nothing to do 24 + AlreadyUpToDate, 25 + } 26 + 27 + /// Pushes local commits to PDS. 28 + /// 29 + /// Reads the remote state record, determines what's new, creates a 30 + /// bundle of the delta, uploads it, and updates the state record. 31 + /// On first push (no existing state), creates a full bundle. 32 + pub async fn push( 33 + client: &PdsClient, 34 + did: &str, 35 + repo_name: &str, 36 + repo_path: &Path, 37 + ) -> Result<PushResult, String> { 38 + // check local branches first (avoids hitting the network for empty repos) 39 + let local_refs = get_local_refs(repo_path).await?; 40 + if local_refs.is_empty() { 41 + return Err("no local branches to push (is the repo empty?)".to_string()); 42 + } 43 + 44 + // read the current remote state (if any) 45 + let existing = client.get_record(did, COLLECTION, repo_name).await?; 46 + 47 + let (remote_state, swap_cid) = match &existing { 48 + Some(record) => { 49 + let state: RepoState = serde_json::from_value(record.value.clone()) 50 + .map_err(|e| format!("failed to parse remote state: {}", e))?; 51 + let cid = record.cid.clone(); 52 + (Some(state), cid) 53 + } 54 + None => (None, None), 55 + }; 56 + 57 + // determine if there's anything new to push 58 + if let Some(ref state) = remote_state { 59 + if refs_match(&local_refs, &state.refs) { 60 + return Ok(PushResult::AlreadyUpToDate); 61 + } 62 + 63 + // check for non-fast-forward 64 + check_fast_forward(repo_path, &state.refs, &local_refs).await?; 65 + } 66 + 67 + // create the bundle 68 + let bundle = match &remote_state { 69 + None => { 70 + // first push — full bundle 71 + tracing::info!("first push to {}, creating full bundle", repo_name); 72 + create_full_bundle(repo_path).await? 73 + } 74 + Some(state) => { 75 + // incremental — bundle since the last known tips 76 + let since_commits: Vec<&str> = state.refs.iter().map(|r| r.sha.as_str()).collect(); 77 + let ref_names: Vec<&str> = local_refs.iter().map(|r| r.name.as_str()).collect(); 78 + tracing::info!( 79 + "incremental push to {}, {} refs since {} prerequisite(s)", 80 + repo_name, 81 + ref_names.len(), 82 + since_commits.len() 83 + ); 84 + create_incremental_bundle(repo_path, &ref_names, &since_commits).await? 85 + } 86 + }; 87 + 88 + // upload the bundle blob(s), chunking if needed 89 + let chunks = chunk_bytes(&bundle.data, DEFAULT_CHUNK_SIZE); 90 + let mut blob_refs = Vec::with_capacity(chunks.len()); 91 + let mut total_bytes: u64 = 0; 92 + 93 + for chunk in &chunks { 94 + let blob_ref = client.upload_blob(chunk.to_vec()).await?; 95 + total_bytes += blob_ref.size; 96 + blob_refs.push(blob_ref); 97 + } 98 + 99 + // build the new bundle entry 100 + let now = chrono_now(); 101 + let entry = BundleEntry { 102 + parts: blob_refs, 103 + prerequisites: bundle.prerequisites, 104 + tips: bundle.tips, 105 + total_size: Some(total_bytes), 106 + created_at: now.clone(), 107 + }; 108 + 109 + // build updated state 110 + let mut bundles = match &remote_state { 111 + Some(state) => state.bundles.clone(), 112 + None => vec![], 113 + }; 114 + bundles.push(entry); 115 + 116 + let new_state = RepoState { 117 + name: Some(repo_name.to_string()), 118 + refs: local_refs, 119 + bundles, 120 + updated_at: now, 121 + }; 122 + 123 + // write the updated state record 124 + let record_value = serde_json::to_value(&new_state) 125 + .map_err(|e| format!("failed to serialize state: {}", e))?; 126 + 127 + client 128 + .put_record(did, COLLECTION, repo_name, record_value, swap_cid) 129 + .await?; 130 + 131 + Ok(PushResult::Pushed { 132 + bundles_uploaded: chunks.len(), 133 + bytes_uploaded: total_bytes, 134 + }) 135 + } 136 + 137 + /// Returns all local branch refs as GitRef entries. 138 + async fn get_local_refs(repo_path: &Path) -> Result<Vec<GitRef>, String> { 139 + let output = tokio::process::Command::new("git") 140 + .args([ 141 + "for-each-ref", 142 + "--format=%(refname) %(objectname)", 143 + "refs/heads/", 144 + ]) 145 + .current_dir(repo_path) 146 + .output() 147 + .await 148 + .map_err(|e| format!("failed to run git for-each-ref: {}", e))?; 149 + 150 + if !output.status.success() { 151 + let stderr = String::from_utf8_lossy(&output.stderr); 152 + return Err(format!("git for-each-ref failed: {}", stderr.trim())); 153 + } 154 + 155 + let stdout = String::from_utf8_lossy(&output.stdout); 156 + let refs: Vec<GitRef> = stdout 157 + .lines() 158 + .filter_map(|line| { 159 + let parts: Vec<&str> = line.splitn(2, ' ').collect(); 160 + if parts.len() == 2 { 161 + Some(GitRef::new(parts[0], parts[1])) 162 + } else { 163 + None 164 + } 165 + }) 166 + .collect(); 167 + 168 + Ok(refs) 169 + } 170 + 171 + /// Checks if local refs match remote refs exactly. 172 + fn refs_match(local: &[GitRef], remote: &[GitRef]) -> bool { 173 + if local.len() != remote.len() { 174 + return false; 175 + } 176 + for local_ref in local { 177 + let found = remote 178 + .iter() 179 + .any(|r| r.name == local_ref.name && r.sha == local_ref.sha); 180 + if !found { 181 + return false; 182 + } 183 + } 184 + true 185 + } 186 + 187 + /// Checks that each remote ref's SHA is an ancestor of the corresponding local ref. 188 + /// 189 + /// This ensures we're only doing fast-forward pushes. Non-fast-forward 190 + /// (force push) is rejected. 191 + async fn check_fast_forward( 192 + repo_path: &Path, 193 + remote_refs: &[GitRef], 194 + local_refs: &[GitRef], 195 + ) -> Result<(), String> { 196 + for remote_ref in remote_refs { 197 + // find corresponding local ref 198 + let local = local_refs.iter().find(|r| r.name == remote_ref.name); 199 + let Some(local) = local else { 200 + // remote has a branch that local doesn't — that's a delete, reject for now 201 + return Err(format!( 202 + "non-fast-forward: remote has {} but local does not (branch deletion not supported)", 203 + remote_ref.name 204 + )); 205 + }; 206 + 207 + // skip if SHAs already match 208 + if local.sha == remote_ref.sha { 209 + continue; 210 + } 211 + 212 + // check if remote SHA is ancestor of local SHA 213 + let output = tokio::process::Command::new("git") 214 + .args(["merge-base", "--is-ancestor", &remote_ref.sha, &local.sha]) 215 + .current_dir(repo_path) 216 + .output() 217 + .await 218 + .map_err(|e| format!("failed to run git merge-base: {}", e))?; 219 + 220 + if !output.status.success() { 221 + return Err(format!( 222 + "non-fast-forward: {} would move from {} to {} (use force push to override)", 223 + remote_ref.name, remote_ref.sha, local.sha 224 + )); 225 + } 226 + } 227 + 228 + Ok(()) 229 + } 230 + 231 + /// Simple UTC timestamp without pulling in chrono crate. 232 + fn chrono_now() -> String { 233 + std::process::Command::new("date") 234 + .args(["-u", "+%Y-%m-%dT%H:%M:%SZ"]) 235 + .output() 236 + .ok() 237 + .and_then(|o| { 238 + if o.status.success() { 239 + Some(String::from_utf8_lossy(&o.stdout).trim().to_string()) 240 + } else { 241 + None 242 + } 243 + }) 244 + .unwrap_or_else(|| "1970-01-01T00:00:00Z".to_string()) 245 + } 246 + 247 + #[cfg(test)] 248 + mod tests { 249 + use super::*; 250 + 251 + #[test] 252 + fn refs_match_identical() { 253 + let a = vec![ 254 + GitRef::new("refs/heads/main", "abc123"), 255 + GitRef::new("refs/heads/dev", "def456"), 256 + ]; 257 + let b = vec![ 258 + GitRef::new("refs/heads/dev", "def456"), 259 + GitRef::new("refs/heads/main", "abc123"), 260 + ]; 261 + assert!(refs_match(&a, &b)); 262 + } 263 + 264 + #[test] 265 + fn refs_match_different_sha() { 266 + let a = vec![GitRef::new("refs/heads/main", "abc123")]; 267 + let b = vec![GitRef::new("refs/heads/main", "different")]; 268 + assert!(!refs_match(&a, &b)); 269 + } 270 + 271 + #[test] 272 + fn refs_match_different_count() { 273 + let a = vec![GitRef::new("refs/heads/main", "abc123")]; 274 + let b = vec![ 275 + GitRef::new("refs/heads/main", "abc123"), 276 + GitRef::new("refs/heads/dev", "def456"), 277 + ]; 278 + assert!(!refs_match(&a, &b)); 279 + } 280 + 281 + #[test] 282 + fn refs_match_empty() { 283 + let a: Vec<GitRef> = vec![]; 284 + let b: Vec<GitRef> = vec![]; 285 + assert!(refs_match(&a, &b)); 286 + } 287 + }
+277
src/remote_helper.rs
··· 1 + //! Git remote helper protocol handler. 2 + //! 3 + //! Speaks git's remote helper protocol over stdin/stdout, translating 4 + //! between standard git commands and the PDS-backed bundle storage. 5 + //! All diagnostic output goes to stderr; stdout is protocol-only. 6 + 7 + use std::path::PathBuf; 8 + 9 + use pds_git_remote::auth; 10 + use pds_git_remote::fetch::{download_and_apply, find_new_bundles, read_remote_state}; 11 + use pds_git_remote::identity; 12 + use pds_git_remote::pds_client::PdsClient; 13 + use pds_git_remote::push; 14 + 15 + /// Parses a `pds://handle/repo-name` URL into (handle, repo_name). 16 + fn parse_pds_url(url: &str) -> Result<(String, String), String> { 17 + let stripped = url 18 + .strip_prefix("pds://") 19 + .ok_or_else(|| format!("invalid PDS URL (expected pds://): {}", url))?; 20 + 21 + let (handle, repo_name) = stripped 22 + .split_once('/') 23 + .ok_or_else(|| format!("invalid PDS URL (expected pds://handle/repo): {}", url))?; 24 + 25 + if handle.is_empty() || repo_name.is_empty() { 26 + return Err(format!("invalid PDS URL (empty handle or repo): {}", url)); 27 + } 28 + 29 + Ok((handle.to_string(), repo_name.to_string())) 30 + } 31 + 32 + /// Resolves the PDS URL for a handle. 33 + /// 34 + /// Checks the `PDS_URL` env var first (for local dev), otherwise does 35 + /// full AT Protocol identity resolution. 36 + async fn resolve_pds_url(handle: &str) -> Result<(String, String), String> { 37 + // local dev override — skip identity resolution 38 + if let Ok(pds_url) = std::env::var("PDS_URL") { 39 + let did = identity::resolve_handle(handle, Some(&pds_url)).await?; 40 + return Ok((pds_url, did)); 41 + } 42 + 43 + // full resolution: handle → DID → PDS endpoint 44 + let resolved = identity::resolve_identity(handle, None, None).await?; 45 + Ok((resolved.pds_url, resolved.did)) 46 + } 47 + 48 + /// Runs the remote helper protocol loop. 49 + /// 50 + /// Called by main when git invokes us as `git-remote-pds <remote> <url>`. 51 + /// Reads commands from stdin, writes responses to stdout. 52 + pub async fn run(_remote_name: &str, url: &str) -> Result<(), String> { 53 + let (handle, repo_name) = parse_pds_url(url)?; 54 + 55 + // read commands from stdin line by line 56 + let stdin = tokio::io::stdin(); 57 + let reader = tokio::io::BufReader::new(stdin); 58 + 59 + use tokio::io::AsyncBufReadExt; 60 + let mut lines = reader.lines(); 61 + 62 + while let Some(line) = lines 63 + .next_line() 64 + .await 65 + .map_err(|e| format!("failed to read stdin: {}", e))? 66 + { 67 + let line = line.trim().to_string(); 68 + 69 + if line.is_empty() { 70 + // empty line can be a terminator for batched commands, skip 71 + continue; 72 + } 73 + 74 + if line == "capabilities" { 75 + print!("push\nfetch\noption\n\n"); 76 + continue; 77 + } 78 + 79 + if let Some(rest) = line.strip_prefix("option ") { 80 + let _ = rest; // we don't support any options 81 + println!("unsupported"); 82 + continue; 83 + } 84 + 85 + if line == "list" || line == "list for-push" { 86 + handle_list(&handle, &repo_name).await?; 87 + continue; 88 + } 89 + 90 + if line.starts_with("fetch ") { 91 + // batch: collect all fetch lines, then process 92 + let mut fetch_lines = vec![line.clone()]; 93 + while let Some(next) = lines 94 + .next_line() 95 + .await 96 + .map_err(|e| format!("failed to read stdin: {}", e))? 97 + { 98 + let next = next.trim().to_string(); 99 + if next.is_empty() { 100 + break; 101 + } 102 + fetch_lines.push(next); 103 + } 104 + handle_fetch(&handle, &repo_name, &fetch_lines).await?; 105 + continue; 106 + } 107 + 108 + if line.starts_with("push ") { 109 + // batch: collect all push lines, then process 110 + let mut push_lines = vec![line.clone()]; 111 + while let Some(next) = lines 112 + .next_line() 113 + .await 114 + .map_err(|e| format!("failed to read stdin: {}", e))? 115 + { 116 + let next = next.trim().to_string(); 117 + if next.is_empty() { 118 + break; 119 + } 120 + push_lines.push(next); 121 + } 122 + handle_push(&handle, &repo_name, &push_lines).await?; 123 + continue; 124 + } 125 + 126 + // unknown command — log and continue 127 + eprintln!("git-remote-pds: unknown command: {}", line); 128 + } 129 + 130 + Ok(()) 131 + } 132 + 133 + /// Handles the `list` / `list for-push` command. 134 + /// 135 + /// Reads the remote state record and outputs refs in the format git expects. 136 + async fn handle_list(handle: &str, repo_name: &str) -> Result<(), String> { 137 + let (pds_url, did) = match resolve_pds_url(handle).await { 138 + Ok(result) => result, 139 + Err(_) => { 140 + // no remote state yet — output empty list 141 + println!(); 142 + return Ok(()); 143 + } 144 + }; 145 + 146 + let client = PdsClient::new(&pds_url); 147 + let state = match read_remote_state(&client, &did, repo_name).await { 148 + Ok(state) => state, 149 + Err(_) => { 150 + // no state record — empty repo 151 + println!(); 152 + return Ok(()); 153 + } 154 + }; 155 + 156 + // output each ref 157 + for git_ref in &state.refs { 158 + println!("{} {}", git_ref.sha, git_ref.name); 159 + } 160 + 161 + // output HEAD symref pointing to the default branch 162 + let default_branch = state 163 + .refs 164 + .iter() 165 + .find(|r| r.name == "refs/heads/main") 166 + .or_else(|| state.refs.iter().find(|r| r.name == "refs/heads/master")) 167 + .or(state.refs.first()); 168 + 169 + if let Some(default) = default_branch { 170 + println!("@{} HEAD", default.name); 171 + } 172 + 173 + // blank line terminates list 174 + println!(); 175 + 176 + Ok(()) 177 + } 178 + 179 + /// Handles a batch of `fetch` commands. 180 + /// 181 + /// Downloads and applies bundles (objects only — git handles ref updates). 182 + async fn handle_fetch( 183 + handle: &str, 184 + repo_name: &str, 185 + _fetch_lines: &[String], 186 + ) -> Result<(), String> { 187 + let (pds_url, did) = resolve_pds_url(handle).await?; 188 + let client = PdsClient::new(&pds_url); 189 + 190 + // read remote state 191 + let state = read_remote_state(&client, &did, repo_name).await?; 192 + 193 + // determine the git dir for the local repo 194 + let git_dir = std::env::var("GIT_DIR").unwrap_or_else(|_| ".git".to_string()); 195 + let repo_path = PathBuf::from(&git_dir); 196 + 197 + // find which bundles we need 198 + let new_bundles = find_new_bundles(&repo_path, &state.bundles).await?; 199 + 200 + if !new_bundles.is_empty() { 201 + eprintln!( 202 + "git-remote-pds: fetching {} bundle(s) from {}", 203 + new_bundles.len(), 204 + handle 205 + ); 206 + download_and_apply(&client, &did, &repo_path, &new_bundles).await?; 207 + } 208 + 209 + // blank line signals fetch complete (no ref updates from our side) 210 + println!(); 211 + 212 + Ok(()) 213 + } 214 + 215 + /// Handles a batch of `push` commands. 216 + /// 217 + /// Authenticates, pushes all local refs, reports results. 218 + async fn handle_push(handle: &str, repo_name: &str, push_lines: &[String]) -> Result<(), String> { 219 + let (pds_url, _did) = resolve_pds_url(handle).await?; 220 + 221 + // resolve authentication 222 + let auth = auth::resolve_auth(handle, &pds_url).await?; 223 + 224 + let client = PdsClient::with_auth(&pds_url, &auth.access_jwt); 225 + 226 + // determine repo path from cwd 227 + let repo_path = 228 + std::env::current_dir().map_err(|e| format!("failed to get current directory: {}", e))?; 229 + 230 + eprintln!("git-remote-pds: pushing to {}/{}", handle, repo_name); 231 + 232 + // push using the library 233 + push::push(&client, &auth.did, repo_name, &repo_path).await?; 234 + 235 + // report ok for each push refspec 236 + for line in push_lines { 237 + // format: "push <src>:<dst>" or "push +<src>:<dst>" 238 + if let Some(refspec) = line.strip_prefix("push ") { 239 + let refspec = refspec.trim_start_matches('+'); 240 + let dst = refspec.split_once(':').map(|(_, d)| d).unwrap_or(refspec); 241 + println!("ok {}", dst); 242 + } 243 + } 244 + 245 + // blank line terminates push response 246 + println!(); 247 + 248 + Ok(()) 249 + } 250 + 251 + #[cfg(test)] 252 + mod tests { 253 + use super::*; 254 + 255 + #[test] 256 + fn parse_pds_url_valid() { 257 + let (handle, repo) = parse_pds_url("pds://alice.test/my-repo").unwrap(); 258 + assert_eq!(handle, "alice.test"); 259 + assert_eq!(repo, "my-repo"); 260 + } 261 + 262 + #[test] 263 + fn parse_pds_url_missing_scheme() { 264 + assert!(parse_pds_url("alice.test/my-repo").is_err()); 265 + } 266 + 267 + #[test] 268 + fn parse_pds_url_missing_repo() { 269 + assert!(parse_pds_url("pds://alice.test").is_err()); 270 + } 271 + 272 + #[test] 273 + fn parse_pds_url_empty_parts() { 274 + assert!(parse_pds_url("pds:///my-repo").is_err()); 275 + assert!(parse_pds_url("pds://alice.test/").is_err()); 276 + } 277 + }
+594
tests/e2e_tests.rs
··· 1 + //! End-to-end tests against a real local PDS. 2 + //! 3 + //! Gated behind the `e2e` feature flag. Requires a running PDS at 4 + //! localhost:3000 with a test account created via scripts/pds-dev/. 5 + //! 6 + //! Run with: cargo test -p pds-git-remote --features e2e -- --test-threads=1 7 + #![cfg(feature = "e2e")] 8 + 9 + use std::path::Path; 10 + 11 + use pds_git_remote::fetch::{FetchResult, clone_repo, fetch_repo}; 12 + use pds_git_remote::pds_client::PdsClient; 13 + use pds_git_remote::push::{PushResult, push}; 14 + use pds_git_remote::types::COLLECTION; 15 + use tokio::fs; 16 + 17 + const PDS_URL: &str = "http://localhost:3000"; 18 + const TEST_HANDLE: &str = "alice.test"; 19 + const TEST_PASSWORD: &str = "test-password-123"; 20 + 21 + /// Checks whether the local PDS is reachable. 22 + async fn pds_is_available() -> bool { 23 + let url = format!("{}/xrpc/_health", PDS_URL); 24 + reqwest::get(&url) 25 + .await 26 + .is_ok_and(|r| r.status().is_success()) 27 + } 28 + 29 + /// Logs in and returns an authenticated client plus the user's DID. 30 + async fn login_client() -> (PdsClient, String) { 31 + let mut client = PdsClient::new(PDS_URL); 32 + let session = client 33 + .login(TEST_HANDLE, TEST_PASSWORD) 34 + .await 35 + .expect("login failed"); 36 + (client, session.did) 37 + } 38 + 39 + /// Skips the test if the PDS is not running. 40 + macro_rules! require_pds { 41 + () => { 42 + if !pds_is_available().await { 43 + eprintln!("SKIP: PDS not available at {}", PDS_URL); 44 + return; 45 + } 46 + }; 47 + } 48 + 49 + /// Generates a unique rkey from a prefix using the current timestamp. 50 + /// 51 + /// Ensures repeated test runs don't collide with leftover records on PDS. 52 + fn unique_rkey(prefix: &str) -> String { 53 + use std::time::{SystemTime, UNIX_EPOCH}; 54 + let nanos = SystemTime::now() 55 + .duration_since(UNIX_EPOCH) 56 + .unwrap() 57 + .as_nanos(); 58 + format!("{}-{}", prefix, nanos) 59 + } 60 + 61 + // -- helpers for git repo setup -- 62 + 63 + /// Writes a file inside a directory. 64 + async fn write_file(dir: &Path, name: &str, content: &str) { 65 + let path = dir.join(name); 66 + if let Some(parent) = path.parent() { 67 + fs::create_dir_all(parent).await.unwrap(); 68 + } 69 + fs::write(&path, content).await.unwrap(); 70 + } 71 + 72 + /// Configures git author so commits work in CI. 73 + async fn configure_git(dir: &Path) { 74 + tokio::process::Command::new("git") 75 + .args(["config", "user.email", "test@test.com"]) 76 + .current_dir(dir) 77 + .output() 78 + .await 79 + .unwrap(); 80 + tokio::process::Command::new("git") 81 + .args(["config", "user.name", "Test"]) 82 + .current_dir(dir) 83 + .output() 84 + .await 85 + .unwrap(); 86 + } 87 + 88 + /// Initializes a git repo in a temp dir. 89 + async fn init_repo() -> tempfile::TempDir { 90 + let tmp = tempfile::tempdir().unwrap(); 91 + tokio::process::Command::new("git") 92 + .args(["init"]) 93 + .current_dir(tmp.path()) 94 + .output() 95 + .await 96 + .unwrap(); 97 + configure_git(tmp.path()).await; 98 + tmp 99 + } 100 + 101 + /// Stages all files and commits. 102 + async fn commit(dir: &Path, message: &str) { 103 + tokio::process::Command::new("git") 104 + .args(["add", "-A"]) 105 + .current_dir(dir) 106 + .output() 107 + .await 108 + .unwrap(); 109 + let output = tokio::process::Command::new("git") 110 + .args(["commit", "-m", message]) 111 + .current_dir(dir) 112 + .output() 113 + .await 114 + .unwrap(); 115 + assert!( 116 + output.status.success(), 117 + "commit failed: {}", 118 + String::from_utf8_lossy(&output.stderr) 119 + ); 120 + } 121 + 122 + /// Returns the HEAD commit SHA. 123 + async fn head_sha(dir: &Path) -> String { 124 + let output = tokio::process::Command::new("git") 125 + .args(["rev-parse", "HEAD"]) 126 + .current_dir(dir) 127 + .output() 128 + .await 129 + .unwrap(); 130 + String::from_utf8_lossy(&output.stdout).trim().to_string() 131 + } 132 + 133 + /// Reads a file's contents as a string. 134 + async fn read_file(dir: &Path, name: &str) -> String { 135 + fs::read_to_string(dir.join(name)).await.unwrap() 136 + } 137 + 138 + // -- E2E tests -- 139 + 140 + /// Login returns a valid DID, tokens, and handle. 141 + #[tokio::test] 142 + async fn e2e_login() { 143 + require_pds!(); 144 + 145 + let mut client = PdsClient::new(PDS_URL); 146 + let session = client.login(TEST_HANDLE, TEST_PASSWORD).await.unwrap(); 147 + 148 + // did must start with "did:plc:" 149 + assert!( 150 + session.did.starts_with("did:plc:"), 151 + "unexpected DID format: {}", 152 + session.did 153 + ); 154 + 155 + // tokens must be non-empty JWTs 156 + assert!(!session.access_jwt.is_empty(), "access_jwt is empty"); 157 + assert!(!session.refresh_jwt.is_empty(), "refresh_jwt is empty"); 158 + 159 + // handle must match what we logged in with 160 + assert_eq!(session.handle, TEST_HANDLE); 161 + } 162 + 163 + /// First push creates a state record with one bundle and no prerequisites. 164 + #[tokio::test] 165 + async fn e2e_first_push() { 166 + require_pds!(); 167 + 168 + let (client, did) = login_client().await; 169 + 170 + // create a repo with one commit 171 + let repo = init_repo().await; 172 + write_file(repo.path(), "README.md", "# Test repo").await; 173 + commit(repo.path(), "initial commit").await; 174 + 175 + // push to PDS with a unique rkey 176 + let rkey = unique_rkey("e2e-first-push"); 177 + let result = push(&client, &did, &rkey, repo.path()) 178 + .await 179 + .expect("push failed"); 180 + 181 + // should report a successful push 182 + match result { 183 + PushResult::Pushed { 184 + bundles_uploaded, 185 + bytes_uploaded, 186 + } => { 187 + assert_eq!(bundles_uploaded, 1, "expected 1 bundle uploaded"); 188 + assert!(bytes_uploaded > 0, "expected non-zero bytes uploaded"); 189 + } 190 + PushResult::AlreadyUpToDate => { 191 + panic!("expected Pushed, got AlreadyUpToDate"); 192 + } 193 + } 194 + 195 + // verify the state record on PDS 196 + let record = client 197 + .get_record(&did, COLLECTION, &rkey) 198 + .await 199 + .expect("get_record failed") 200 + .expect("state record should exist"); 201 + 202 + let state: serde_json::Value = record.value; 203 + 204 + // should have 1 bundle with no prerequisites 205 + let bundles = state["bundles"].as_array().unwrap(); 206 + assert_eq!(bundles.len(), 1, "expected 1 bundle in state"); 207 + assert!( 208 + bundles[0]["prerequisites"].as_array().unwrap().is_empty(), 209 + "first bundle should have no prerequisites" 210 + ); 211 + assert!( 212 + !bundles[0]["tips"].as_array().unwrap().is_empty(), 213 + "first bundle should have tips" 214 + ); 215 + 216 + // should have refs 217 + let refs = state["refs"].as_array().unwrap(); 218 + assert!(!refs.is_empty(), "state should have refs"); 219 + } 220 + 221 + /// Two pushes: state grows to 2 bundles, second has prerequisites. 222 + #[tokio::test] 223 + async fn e2e_incremental_push() { 224 + require_pds!(); 225 + 226 + let (client, did) = login_client().await; 227 + 228 + // create repo and first push 229 + let repo = init_repo().await; 230 + write_file(repo.path(), "file1.txt", "first file").await; 231 + commit(repo.path(), "first commit").await; 232 + 233 + let rkey = unique_rkey("e2e-incremental"); 234 + let result = push(&client, &did, &rkey, repo.path()) 235 + .await 236 + .expect("first push failed"); 237 + assert!( 238 + matches!(result, PushResult::Pushed { .. }), 239 + "first push should succeed" 240 + ); 241 + 242 + // add more commits and push again 243 + write_file(repo.path(), "file2.txt", "second file").await; 244 + commit(repo.path(), "second commit").await; 245 + 246 + let result = push(&client, &did, &rkey, repo.path()) 247 + .await 248 + .expect("second push failed"); 249 + assert!( 250 + matches!(result, PushResult::Pushed { .. }), 251 + "second push should succeed" 252 + ); 253 + 254 + // verify state record has 2 bundles 255 + let record = client 256 + .get_record(&did, COLLECTION, &rkey) 257 + .await 258 + .expect("get_record failed") 259 + .expect("state record should exist"); 260 + 261 + let state: serde_json::Value = record.value; 262 + let bundles = state["bundles"].as_array().unwrap(); 263 + assert_eq!(bundles.len(), 2, "expected 2 bundles in state"); 264 + 265 + // second bundle should have prerequisites (the tips of the first) 266 + let second_prereqs = bundles[1]["prerequisites"].as_array().unwrap(); 267 + assert!( 268 + !second_prereqs.is_empty(), 269 + "second bundle should have prerequisites" 270 + ); 271 + 272 + // the tips of bundle 1 should be in the prerequisites of bundle 2 273 + let first_tips = bundles[0]["tips"].as_array().unwrap(); 274 + for tip in first_tips { 275 + assert!( 276 + second_prereqs.contains(tip), 277 + "second bundle prerequisites should include first bundle's tips" 278 + ); 279 + } 280 + } 281 + 282 + /// Push twice with no new commits returns AlreadyUpToDate. 283 + #[tokio::test] 284 + async fn e2e_already_up_to_date() { 285 + require_pds!(); 286 + 287 + let (client, did) = login_client().await; 288 + 289 + // create repo and push 290 + let repo = init_repo().await; 291 + write_file(repo.path(), "file.txt", "content").await; 292 + commit(repo.path(), "initial").await; 293 + 294 + let rkey = unique_rkey("e2e-up-to-date"); 295 + push(&client, &did, &rkey, repo.path()) 296 + .await 297 + .expect("first push failed"); 298 + 299 + // push again with no new commits 300 + let result = push(&client, &did, &rkey, repo.path()) 301 + .await 302 + .expect("second push failed"); 303 + 304 + assert!( 305 + matches!(result, PushResult::AlreadyUpToDate), 306 + "expected AlreadyUpToDate on second push with no new commits" 307 + ); 308 + } 309 + 310 + /// Upload a blob, reference it in a record, then download — bytes must match. 311 + /// 312 + /// Blobs on PDS are only persisted once referenced by a record, so we 313 + /// create a minimal record containing the blob ref before downloading. 314 + #[tokio::test] 315 + async fn e2e_blob_round_trip() { 316 + require_pds!(); 317 + 318 + let (client, did) = login_client().await; 319 + 320 + // upload some arbitrary bytes 321 + let data = b"hello from pds-git-remote e2e test!".to_vec(); 322 + let blob_ref = client 323 + .upload_blob(data.clone()) 324 + .await 325 + .expect("upload_blob failed"); 326 + 327 + // the returned blob ref should have a CID 328 + assert!(!blob_ref.cid().is_empty(), "blob CID should be non-empty"); 329 + assert_eq!(blob_ref.size, data.len() as u64); 330 + 331 + // create a record referencing the blob so PDS persists it 332 + let record = serde_json::json!({ 333 + "blob": blob_ref, 334 + "createdAt": "2026-02-13T00:00:00Z", 335 + }); 336 + let rkey = unique_rkey("e2e-blob-test"); 337 + client 338 + .put_record(&did, COLLECTION, &rkey, record, None) 339 + .await 340 + .expect("put_record failed"); 341 + 342 + // download the blob back 343 + let downloaded = client 344 + .get_blob(&did, blob_ref.cid()) 345 + .await 346 + .expect("get_blob failed"); 347 + 348 + assert_eq!(downloaded, data, "downloaded bytes should match uploaded"); 349 + } 350 + 351 + /// Clone from PDS into a new repo — files and history must match the source. 352 + #[tokio::test] 353 + async fn e2e_clone() { 354 + require_pds!(); 355 + 356 + let (client, did) = login_client().await; 357 + 358 + // create a source repo with two commits 359 + let source = init_repo().await; 360 + write_file(source.path(), "README.md", "# My Project").await; 361 + write_file(source.path(), "src/main.rs", "fn main() {}").await; 362 + commit(source.path(), "initial commit").await; 363 + write_file(source.path(), "src/lib.rs", "pub fn hello() {}").await; 364 + commit(source.path(), "add lib").await; 365 + 366 + let source_sha = head_sha(source.path()).await; 367 + 368 + // push to PDS 369 + let rkey = unique_rkey("e2e-clone"); 370 + push(&client, &did, &rkey, source.path()) 371 + .await 372 + .expect("push failed"); 373 + 374 + // clone into a new empty repo 375 + let dest = init_repo().await; 376 + let result = clone_repo(&client, &did, &rkey, dest.path()) 377 + .await 378 + .expect("clone failed"); 379 + 380 + // should have applied 1 bundle 381 + match result { 382 + FetchResult::Applied { 383 + bundles_applied, 384 + bytes_downloaded, 385 + } => { 386 + assert_eq!(bundles_applied, 1, "expected 1 bundle applied"); 387 + assert!(bytes_downloaded > 0, "expected non-zero bytes"); 388 + } 389 + FetchResult::AlreadyUpToDate => { 390 + panic!("expected Applied, got AlreadyUpToDate"); 391 + } 392 + } 393 + 394 + // verify HEAD matches source 395 + let dest_sha = head_sha(dest.path()).await; 396 + assert_eq!(dest_sha, source_sha, "cloned HEAD should match source"); 397 + 398 + // verify file contents match 399 + let readme = read_file(dest.path(), "README.md").await; 400 + assert_eq!(readme, "# My Project"); 401 + let main_rs = read_file(dest.path(), "src/main.rs").await; 402 + assert_eq!(main_rs, "fn main() {}"); 403 + let lib_rs = read_file(dest.path(), "src/lib.rs").await; 404 + assert_eq!(lib_rs, "pub fn hello() {}"); 405 + } 406 + 407 + /// Fetch after clone: push new commits, fetch into clone, verify update. 408 + #[tokio::test] 409 + async fn e2e_fetch() { 410 + require_pds!(); 411 + 412 + let (client, did) = login_client().await; 413 + 414 + // create source repo and push 415 + let source = init_repo().await; 416 + write_file(source.path(), "file1.txt", "first").await; 417 + commit(source.path(), "first").await; 418 + 419 + let rkey = unique_rkey("e2e-fetch"); 420 + push(&client, &did, &rkey, source.path()) 421 + .await 422 + .expect("first push failed"); 423 + 424 + // clone into dest 425 + let dest = init_repo().await; 426 + clone_repo(&client, &did, &rkey, dest.path()) 427 + .await 428 + .expect("clone failed"); 429 + 430 + // add more commits to source and push again 431 + write_file(source.path(), "file2.txt", "second").await; 432 + commit(source.path(), "second").await; 433 + let source_sha = head_sha(source.path()).await; 434 + 435 + push(&client, &did, &rkey, source.path()) 436 + .await 437 + .expect("second push failed"); 438 + 439 + // fetch into dest 440 + let result = fetch_repo(&client, &did, &rkey, dest.path()) 441 + .await 442 + .expect("fetch failed"); 443 + 444 + // should have applied 1 new bundle (the incremental one) 445 + match result { 446 + FetchResult::Applied { 447 + bundles_applied, 448 + bytes_downloaded, 449 + } => { 450 + assert_eq!(bundles_applied, 1, "expected 1 new bundle fetched"); 451 + assert!(bytes_downloaded > 0, "expected non-zero bytes"); 452 + } 453 + FetchResult::AlreadyUpToDate => { 454 + panic!("expected Applied, got AlreadyUpToDate"); 455 + } 456 + } 457 + 458 + // verify HEAD matches source after fetch 459 + let dest_sha = head_sha(dest.path()).await; 460 + assert_eq!(dest_sha, source_sha, "fetched HEAD should match source"); 461 + 462 + // verify the new file is present 463 + let file2 = read_file(dest.path(), "file2.txt").await; 464 + assert_eq!(file2, "second"); 465 + } 466 + 467 + /// Push via git remote helper binary, then clone — files and HEAD must match. 468 + #[tokio::test] 469 + async fn e2e_remote_helper_push_and_clone() { 470 + require_pds!(); 471 + 472 + let binary = env!("CARGO_BIN_EXE_git-remote-pds"); 473 + 474 + // put the binary's directory on PATH so git can find it 475 + let bin_dir = std::path::Path::new(binary) 476 + .parent() 477 + .unwrap() 478 + .to_str() 479 + .unwrap(); 480 + let path_env = format!("{}:{}", bin_dir, std::env::var("PATH").unwrap_or_default()); 481 + 482 + let rkey = unique_rkey("e2e-remote-helper"); 483 + 484 + // create source repo with files 485 + let source = init_repo().await; 486 + write_file(source.path(), "README.md", "# Remote Helper Test").await; 487 + write_file(source.path(), "src/app.rs", "fn app() {}").await; 488 + commit(source.path(), "initial commit").await; 489 + 490 + let source_sha = head_sha(source.path()).await; 491 + 492 + // add the pds remote 493 + let remote_url = format!("pds://{}/{}", TEST_HANDLE, rkey); 494 + let output = tokio::process::Command::new("git") 495 + .args(["remote", "add", "pds", &remote_url]) 496 + .current_dir(source.path()) 497 + .output() 498 + .await 499 + .unwrap(); 500 + assert!( 501 + output.status.success(), 502 + "git remote add failed: {}", 503 + String::from_utf8_lossy(&output.stderr) 504 + ); 505 + 506 + // get the default branch name 507 + let output = tokio::process::Command::new("git") 508 + .args(["branch", "--show-current"]) 509 + .current_dir(source.path()) 510 + .output() 511 + .await 512 + .unwrap(); 513 + let branch = String::from_utf8_lossy(&output.stdout).trim().to_string(); 514 + 515 + // push via remote helper 516 + let output = tokio::process::Command::new("git") 517 + .args(["push", "pds", &branch]) 518 + .current_dir(source.path()) 519 + .env("PATH", &path_env) 520 + .env("PDS_URL", PDS_URL) 521 + .env("PDS_HANDLE", TEST_HANDLE) 522 + .env("PDS_PASSWORD", TEST_PASSWORD) 523 + .output() 524 + .await 525 + .unwrap(); 526 + assert!( 527 + output.status.success(), 528 + "git push failed: stdout={} stderr={}", 529 + String::from_utf8_lossy(&output.stdout), 530 + String::from_utf8_lossy(&output.stderr) 531 + ); 532 + 533 + // clone via remote helper into a new directory 534 + let dest = tempfile::tempdir().unwrap(); 535 + let output = tokio::process::Command::new("git") 536 + .args(["clone", &remote_url, dest.path().to_str().unwrap()]) 537 + .env("PATH", &path_env) 538 + .env("PDS_URL", PDS_URL) 539 + .env("PDS_HANDLE", TEST_HANDLE) 540 + .env("PDS_PASSWORD", TEST_PASSWORD) 541 + .output() 542 + .await 543 + .unwrap(); 544 + assert!( 545 + output.status.success(), 546 + "git clone failed: stdout={} stderr={}", 547 + String::from_utf8_lossy(&output.stdout), 548 + String::from_utf8_lossy(&output.stderr) 549 + ); 550 + 551 + // verify HEAD SHAs match 552 + let dest_sha = head_sha(dest.path()).await; 553 + assert_eq!(dest_sha, source_sha, "cloned HEAD should match source HEAD"); 554 + 555 + // verify file contents match 556 + let readme = read_file(dest.path(), "README.md").await; 557 + assert_eq!(readme, "# Remote Helper Test"); 558 + let app_rs = read_file(dest.path(), "src/app.rs").await; 559 + assert_eq!(app_rs, "fn app() {}"); 560 + } 561 + 562 + /// Fetch with no new commits returns AlreadyUpToDate. 563 + #[tokio::test] 564 + async fn e2e_fetch_already_up_to_date() { 565 + require_pds!(); 566 + 567 + let (client, did) = login_client().await; 568 + 569 + // create source repo and push 570 + let source = init_repo().await; 571 + write_file(source.path(), "file.txt", "content").await; 572 + commit(source.path(), "initial").await; 573 + 574 + let rkey = unique_rkey("e2e-fetch-utd"); 575 + push(&client, &did, &rkey, source.path()) 576 + .await 577 + .expect("push failed"); 578 + 579 + // clone into dest 580 + let dest = init_repo().await; 581 + clone_repo(&client, &did, &rkey, dest.path()) 582 + .await 583 + .expect("clone failed"); 584 + 585 + // fetch again with no new commits 586 + let result = fetch_repo(&client, &did, &rkey, dest.path()) 587 + .await 588 + .expect("fetch failed"); 589 + 590 + assert!( 591 + matches!(result, FetchResult::AlreadyUpToDate), 592 + "expected AlreadyUpToDate when no new commits" 593 + ); 594 + }
+114
tests/push_tests.rs
··· 1 + //! Integration tests for push flow logic. 2 + //! 3 + //! Tests the bundle-creation and state-assembly parts of push. 4 + //! Full end-to-end push tests require a running PDS (see scripts/pds-dev/). 5 + 6 + use std::path::Path; 7 + use tokio::fs; 8 + 9 + use pds_git_remote::bundle::create_full_bundle; 10 + use pds_git_remote::pds_client::PdsClient; 11 + use pds_git_remote::push::push; 12 + 13 + /// Helper: write a file inside a directory. 14 + async fn write_file(dir: &Path, name: &str, content: &str) { 15 + let path = dir.join(name); 16 + if let Some(parent) = path.parent() { 17 + fs::create_dir_all(parent).await.unwrap(); 18 + } 19 + fs::write(&path, content).await.unwrap(); 20 + } 21 + 22 + /// Helper: configure git author so commits work in CI. 23 + async fn configure_git(dir: &Path) { 24 + tokio::process::Command::new("git") 25 + .args(["config", "user.email", "test@test.com"]) 26 + .current_dir(dir) 27 + .output() 28 + .await 29 + .unwrap(); 30 + tokio::process::Command::new("git") 31 + .args(["config", "user.name", "Test"]) 32 + .current_dir(dir) 33 + .output() 34 + .await 35 + .unwrap(); 36 + } 37 + 38 + /// Helper: init a git repo in a temp dir. 39 + async fn init_repo() -> tempfile::TempDir { 40 + let tmp = tempfile::tempdir().unwrap(); 41 + tokio::process::Command::new("git") 42 + .args(["init"]) 43 + .current_dir(tmp.path()) 44 + .output() 45 + .await 46 + .unwrap(); 47 + configure_git(tmp.path()).await; 48 + tmp 49 + } 50 + 51 + /// Helper: stage all and commit. 52 + async fn commit(dir: &Path, message: &str) { 53 + tokio::process::Command::new("git") 54 + .args(["add", "-A"]) 55 + .current_dir(dir) 56 + .output() 57 + .await 58 + .unwrap(); 59 + let output = tokio::process::Command::new("git") 60 + .args(["commit", "-m", message]) 61 + .current_dir(dir) 62 + .output() 63 + .await 64 + .unwrap(); 65 + assert!( 66 + output.status.success(), 67 + "commit failed: {}", 68 + String::from_utf8_lossy(&output.stderr) 69 + ); 70 + } 71 + 72 + /// Push to a nonexistent PDS should fail with a connection error, not a panic. 73 + /// 74 + /// This verifies the push function handles network errors gracefully. 75 + #[tokio::test] 76 + async fn push_to_unreachable_pds_returns_error() { 77 + let repo = init_repo().await; 78 + write_file(repo.path(), "f.txt", "hello").await; 79 + commit(repo.path(), "initial").await; 80 + 81 + // point at a PDS that doesn't exist 82 + let client = PdsClient::with_auth("http://127.0.0.1:19999", "fake-token"); 83 + 84 + let result = push(&client, "did:plc:test", "test-repo", repo.path()).await; 85 + assert!(result.is_err()); 86 + } 87 + 88 + /// Push on an empty repo (no commits) should return an error. 89 + #[tokio::test] 90 + async fn push_empty_repo_returns_error() { 91 + let repo = init_repo().await; 92 + 93 + let client = PdsClient::with_auth("http://127.0.0.1:19999", "fake-token"); 94 + let result = push(&client, "did:plc:test", "test-repo", repo.path()).await; 95 + assert!(result.is_err()); 96 + assert!(result.unwrap_err().contains("no local branches")); 97 + } 98 + 99 + /// Verify that create_full_bundle produces data suitable for a first push. 100 + #[tokio::test] 101 + async fn first_push_creates_full_bundle() { 102 + let repo = init_repo().await; 103 + write_file(repo.path(), "index.md", "# Site").await; 104 + commit(repo.path(), "init").await; 105 + 106 + let bundle = create_full_bundle(repo.path()).await.unwrap(); 107 + 108 + // full bundle has no prerequisites 109 + assert!(bundle.prerequisites.is_empty()); 110 + // has at least one tip 111 + assert!(!bundle.tips.is_empty()); 112 + // bundle data is non-trivial 113 + assert!(bundle.data.len() > 10); 114 + }
+8
todo.txt
··· 1 + 2 + 3 + - more thorough tests 4 + - inspect pds via pds browser 5 + - test git commands directly via cli 6 + - add an e2e test that tests with larger files 7 + - add an e2e test that tests what happens with a conflict 8 + - separate into own repo