···5566[workspace]
7788+[[bin]]
99+name = "git-remote-pds"
1010+path = "src/main.rs"
1111+812[dependencies]
1313+clap = { version = "4.5", features = ["derive"] }
914reqwest = { version = "0.12", features = ["json", "rustls-tls"], default-features = false }
1015serde = { version = "1.0", features = ["derive"] }
1116serde_json = "1.0"
1217tempfile = "3.13.0"
1318tokio = { version = "1", features = ["full"] }
1414-tracing = "0.1"
1919+tracing = { version = "0.1", features = ["log"] }
2020+tracing-subscriber = { version = "0.3", features = ["env-filter", "fmt"] }
2121+2222+[features]
2323+e2e = []
15241625[dev-dependencies]
1726tempfile = "3.13.0"
+71
e2e-testing.md
···11+# E2E Testing for pds-git-remote against Local PDS
22+33+## Context
44+55+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.
66+77+## Approach
88+99+### 1. Add `e2e` feature flag to Cargo.toml
1010+1111+**File:** `crates/pds-git-remote/Cargo.toml`
1212+1313+Add a `[features]` section with a marker feature:
1414+```toml
1515+[features]
1616+e2e = []
1717+```
1818+1919+No dependency changes — just a gate for test compilation.
2020+2121+### 2. Create E2E test file
2222+2323+**File:** `crates/pds-git-remote/tests/e2e_tests.rs` (new)
2424+2525+Gated with `#![cfg(feature = "e2e")]` at the module level.
2626+2727+**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.
2828+2929+**Constants:** `PDS_URL`, `TEST_HANDLE`, `TEST_PASSWORD` matching the defaults from `scripts/pds-dev/create-account.sh`.
3030+3131+**Helper:** `login_client()` → `(PdsClient, did)` — logs in and returns an authenticated client.
3232+3333+**Test cases (5):**
3434+3535+| Test | What it verifies |
3636+|------|-----------------|
3737+| `e2e_login` | `PdsClient::login()` returns valid DID, tokens, handle |
3838+| `e2e_first_push` | Full push flow: commit → `push()` → state record created with 1 bundle, no prerequisites |
3939+| `e2e_incremental_push` | Two pushes: state record grows to 2 bundles, second has prerequisites, swap_record CAS works |
4040+| `e2e_already_up_to_date` | Push twice with no new commits → `PushResult::AlreadyUpToDate` |
4141+| `e2e_blob_round_trip` | `upload_blob()` then `get_blob()` returns identical bytes |
4242+4343+Each test uses a unique `repo_name` rkey to avoid collision.
4444+4545+### 3. Create test harness script
4646+4747+**File:** `scripts/pds-dev/run-e2e.sh` (new, executable)
4848+4949+Flow:
5050+1. `reset.sh` — clean slate
5151+2. `start.sh` — start PDS, wait for health check
5252+3. `create-account.sh` — create test account
5353+4. `cargo test -p pds-git-remote --features e2e -- --test-threads=1`
5454+5. `stop.sh` on exit (via `trap`, unless `--keep` flag passed)
5555+5656+Tests run serialized (`--test-threads=1`) since they share one PDS account.
5757+5858+## Files to create/modify
5959+6060+| File | Action |
6161+|------|--------|
6262+| `crates/pds-git-remote/Cargo.toml` | Add `[features]` section |
6363+| `crates/pds-git-remote/tests/e2e_tests.rs` | New: 5 E2E tests + helpers |
6464+| `scripts/pds-dev/run-e2e.sh` | New: harness script |
6565+6666+## Verification
6767+6868+- `cargo test -p pds-git-remote --quiet` — existing 26 tests still pass (E2E tests not compiled)
6969+- `cargo test -p pds-git-remote --features e2e --quiet` — compiles E2E tests, skips gracefully if no PDS
7070+- `./scripts/pds-dev/run-e2e.sh` — full E2E suite against real PDS (requires Docker)
7171+- `cargo test -p lichen-cms -p lichen-server --quiet` — workspace unaffected
+53-23
plan.md
···17171818---
19192020+## Reference libraries
2121+2222+### Primary: atrium (recommended)
2323+2424+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:
2525+2626+- **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.
2727+- **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.
2828+- **`InputDataOrBytes` / `OutputDataOrBytes`**: clean separation between JSON payloads and raw byte payloads (blobs) in a single API surface. Blob upload sends `InputDataOrBytes::Bytes(Vec<u8>)`.
2929+- **Validated string newtypes**: `Did`, `Handle`, `Nsid`, `Tid`, `RecordKey`, etc. — all use a `string_newtype!` macro with validation at construction via `FromStr` and `Deserialize`.
3030+- **`Object<T>` wrapper**: generated structs are wrapped to capture unknown fields via `#[serde(flatten)]` for forward compatibility.
3131+- **Generic `Store<K, V>` trait**: pluggable session persistence (memory, file, etc.) via an async trait with `get/set/del/clear`.
3232+- **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.
3333+- **Hierarchical namespace API**: `agent.api.com.atproto.repo.create_record(...)` mirrors the lexicon namespace.
3434+3535+### Secondary: atproto-rs
3636+3737+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:
3838+3939+- **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.
4040+- **`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.
4141+- **XRPC client shape**: `XrpcClient { service: Url, client: reqwest::Client, headers: HashMap }` with `query()` for GET and `procedure()` for POST.
4242+4343+### Our approach
4444+4545+- **`anyhow` for errors**: use `anyhow::Result` throughout for parity with the rest of the lichen codebase. No `thiserror`.
4646+- Keep it simpler than either reference — we only need a handful of XRPC calls, not a full SDK.
4747+4848+---
4949+2050## Phase 1: Core types and PDS client
21512252Foundation layer — types that model the PDS state record and an HTTP client for the PDS XRPC API.
···61916292Combine the PDS client and bundle operations into a complete push.
63936464-- [ ] implement push logic in `push.rs`:
9494+- [x] implement push logic in `push.rs`:
6595 - read current state record from PDS (or handle first-push where none exists)
6696 - determine what's new: compare local refs to remote refs
6797 - create incremental bundle (or full bundle on first push)
6898 - chunk if needed, upload blob(s) via `upload_blob`
6999 - append new `BundleEntry` to state, update refs
70100 - write updated state record via `put_record`
7171-- [ ] handle edge cases:
101101+- [x] handle edge cases:
72102 - first push (no existing state record) → create full bundle + new record
73103 - nothing to push (refs match) → no-op
74104 - non-fast-forward → reject with error (no force push for now)
7575-- [ ] add integration tests with a mock PDS server (or test against local PDS)
105105+- [x] add integration tests with a mock PDS server (or test against local PDS)
7610677107---
78108···8011081111Set up a local PDS server via Docker for integration testing. Scripts live in `scripts/pds-dev/`.
821128383-- [ ] create `scripts/pds-dev/compose.yaml`:
113113+- [x] create `scripts/pds-dev/compose.yaml`:
84114 - single service: `ghcr.io/bluesky-social/pds:0.4` on port 3000
85115 - volume mount `./pds-data:/pds` for persistence
86116 - env_file pointing to `pds.env`
8787-- [ ] create `scripts/pds-dev/setup.sh`:
117117+- [x] create `scripts/pds-dev/setup.sh`:
88118 - generate secrets (`PDS_JWT_SECRET`, `PDS_ADMIN_PASSWORD`, rotation key)
89119 - write `pds.env` with local-dev defaults (`PDS_HOSTNAME=localhost`, `PDS_DEV_MODE=true`, `PDS_INVITE_REQUIRED=false`)
90120 - create data directory
91121 - print admin password for reference
9292-- [ ] create `scripts/pds-dev/start.sh`:
122122+- [x] create `scripts/pds-dev/start.sh`:
93123 - run `setup.sh` if `pds.env` doesn't exist yet
94124 - `docker compose up -d`
95125 - wait for health check (`/xrpc/_health`) with timeout
9696-- [ ] create `scripts/pds-dev/create-account.sh`:
126126+- [x] create `scripts/pds-dev/create-account.sh`:
97127 - accept handle and password as args (defaults: `test.localhost` / `test-password-123`)
98128 - call `com.atproto.server.createAccount` XRPC endpoint
99129 - print the DID and access token
100100-- [ ] create `scripts/pds-dev/login.sh`:
130130+- [x] create `scripts/pds-dev/login.sh`:
101131 - call `com.atproto.server.createSession` for a given handle/password
102132 - print `accessJwt` for use in manual testing or piping to other scripts
103103-- [ ] create `scripts/pds-dev/stop.sh`:
133133+- [x] create `scripts/pds-dev/stop.sh`:
104134 - `docker compose down`
105105-- [ ] create `scripts/pds-dev/reset.sh`:
135135+- [x] create `scripts/pds-dev/reset.sh`:
106136 - `docker compose down -v` and `rm -rf pds-data` to wipe all state
107107-- [ ] add `scripts/pds-dev/README.md` with quick-start instructions
108108-- [ ] add `scripts/pds-dev/` to `.gitignore` for `pds-data/` and `pds.env` (generated secrets)
137137+- [x] add `scripts/pds-dev/README.md` with quick-start instructions
138138+- [x] add `scripts/pds-dev/` to `.gitignore` for `pds-data/` and `pds.env` (generated secrets)
109139110140---
111141···113143114144Download bundle chain from PDS and apply to local repo.
115145116116-- [ ] implement fetch/clone logic in `fetch.rs`:
146146+- [x] implement fetch/clone logic in `fetch.rs`:
117147 - resolve `pds://handle/repo-name` into DID + PDS endpoint + rkey
118148 - read state record → get bundle chain and refs
119149 - for clone: download all bundles in order (oldest first), apply each
120150 - for fetch: compare local refs to remote, skip bundles whose tips we already have, download and apply only new ones
121151 - set up local refs from state record
122122-- [ ] handle chunked bundles (reassemble parts before unbundling)
123123-- [ ] add integration tests
152152+- [x] handle chunked bundles (reassemble parts before unbundling)
153153+- [x] add integration tests
124154125155---
126156···128158129159Implement the `git-remote-pds` binary that speaks git's remote helper protocol on stdin/stdout.
130160131131-- [ ] add `[[bin]]` target to `Cargo.toml` for `git-remote-pds`
132132-- [ ] implement remote helper protocol in `remote_helper.rs`:
161161+- [x] add `[[bin]]` target to `Cargo.toml` for `git-remote-pds`
162162+- [x] implement remote helper protocol in `remote_helper.rs`:
133163 - `capabilities` → respond with `push` and `fetch`
134164 - `list` / `list for-push` → read refs from PDS state record
135165 - `fetch <sha> <ref>` → download and apply bundle chain
136166 - `push <src>:<dst>` → create bundle, upload, update state
137137-- [ ] implement CLI auth in `auth.rs`:
138138- - `pds-git auth login` → open browser for atproto OAuth, cache token locally
139139- - token storage in `~/.config/pds-git-remote/` or platform-appropriate config dir
140140- - token refresh on expiry
141141-- [ ] add `clap`-based CLI for auth subcommands
142142-- [ ] end-to-end test: init repo, add remote, push, clone elsewhere, verify content matches
167167+- [x] implement CLI auth in `auth.rs`:
168168+ - `pds-git auth login` → login via createSession, cache token locally
169169+ - token storage in `~/.config/pds-git-remote/auth.json`
170170+ - env var auth (`PDS_ACCESS_TOKEN`/`PDS_DID` or `PDS_HANDLE`/`PDS_PASSWORD`)
171171+- [x] add `clap`-based CLI for auth subcommands
172172+- [x] end-to-end test: init repo, add remote, push, clone elsewhere, verify content matches
143173144174---
145175
+34
scripts/nixtests/README.md
···11+# Nix-based PDS scripts
22+33+Run a local ATProto PDS using the `bluesky-pds` package from nixpkgs (no Docker required).
44+55+## Prerequisites
66+77+```bash
88+nix develop # enters dev shell with `pds` on PATH
99+```
1010+1111+## Quick start
1212+1313+```bash
1414+./scripts/nixtests/start.sh # generates secrets on first run, starts PDS
1515+./scripts/nixtests/create-account.sh # creates test.localhost account
1616+./scripts/nixtests/login.sh # prints access token
1717+```
1818+1919+## Scripts
2020+2121+| Script | Description |
2222+|--------|-------------|
2323+| `setup.sh` | Generate secrets and write `pds.env` (called automatically by `start.sh`) |
2424+| `start.sh` | Start the PDS in the background with health checking |
2525+| `stop.sh` | Gracefully stop the PDS |
2626+| `reset.sh` | Stop and wipe all data for a fresh start |
2727+| `create-account.sh` | Create a test account (default: `test.localhost`) |
2828+| `login.sh` | Log in and print an access token |
2929+3030+## Notes
3131+3232+- Data is stored in `scripts/nixtests/pds-data/` (gitignored)
3333+- PDS listens on port 3000 by default
3434+- Logs are written to `pds-data/pds.log`
···11+#!/usr/bin/env bash
22+# Logs in to the local PDS and prints the access token.
33+#
44+# Usage:
55+# ./login.sh # defaults: test.localhost / test-password-123
66+# ./login.sh myhandle.localhost mypass
77+#
88+# The access token is printed on its own line for piping:
99+# TOKEN=$(./login.sh)
1010+set -euo pipefail
1111+1212+HANDLE="${1:-test.localhost}"
1313+PASSWORD="${2:-test-password-123}"
1414+PDS_URL="${PDS_URL:-http://localhost:3000}"
1515+1616+RESPONSE=$(curl -s -X POST \
1717+ -H "Content-Type: application/json" \
1818+ -d "{
1919+ \"identifier\": \"${HANDLE}\",
2020+ \"password\": \"${PASSWORD}\"
2121+ }" \
2222+ "${PDS_URL}/xrpc/com.atproto.server.createSession")
2323+2424+# check for error
2525+if echo "${RESPONSE}" | grep -q '"error"'; then
2626+ echo "Login failed:" >&2
2727+ echo "${RESPONSE}" | python3 -m json.tool 2>/dev/null >&2 || echo "${RESPONSE}" >&2
2828+ exit 1
2929+fi
3030+3131+# if stdout is a terminal, print labels; otherwise just the token
3232+if [ -t 1 ]; then
3333+ DID=$(echo "${RESPONSE}" | python3 -c "import sys,json; print(json.load(sys.stdin)['did'])" 2>/dev/null || echo "unknown")
3434+ ACCESS_JWT=$(echo "${RESPONSE}" | python3 -c "import sys,json; print(json.load(sys.stdin)['accessJwt'])" 2>/dev/null || echo "unknown")
3535+ echo "Logged in as ${HANDLE} (${DID})"
3636+ echo ""
3737+ echo "Access token:"
3838+ echo " ${ACCESS_JWT}"
3939+else
4040+ # piped — just the token
4141+ echo "${RESPONSE}" | python3 -c "import sys,json; print(json.load(sys.stdin)['accessJwt'])"
4242+fi
+12
scripts/nixtests/reset.sh
···11+#!/usr/bin/env bash
22+# Stops the PDS and wipes all data for a fresh start.
33+set -euo pipefail
44+55+SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
66+77+bash "${SCRIPT_DIR}/stop.sh"
88+99+echo "Removing pds-data/ and pds.env..."
1010+rm -rf "${SCRIPT_DIR}/pds-data"
1111+rm -f "${SCRIPT_DIR}/pds.env"
1212+echo "Reset complete."
+47
scripts/nixtests/setup.sh
···11+#!/usr/bin/env bash
22+# Generates secrets and writes pds.env for local development.
33+# Run once before starting the PDS for the first time.
44+set -euo pipefail
55+66+SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
77+ENV_FILE="${SCRIPT_DIR}/pds.env"
88+DATA_DIR="${SCRIPT_DIR}/pds-data"
99+1010+if [ -f "${ENV_FILE}" ]; then
1111+ echo "pds.env already exists, skipping setup."
1212+ echo "Run reset.sh first if you want a fresh environment."
1313+ exit 0
1414+fi
1515+1616+echo "Generating secrets..."
1717+1818+JWT_SECRET=$(openssl rand --hex 16)
1919+ADMIN_PASSWORD=$(openssl rand --hex 16)
2020+ROTATION_KEY=$(openssl ecparam -name secp256k1 -genkey -noout -outform DER 2>/dev/null | \
2121+ tail -c +8 | head -c 32 | xxd -p -c 32)
2222+2323+mkdir -p "${DATA_DIR}"
2424+2525+cat > "${ENV_FILE}" <<EOF
2626+PDS_HOSTNAME=localhost
2727+PDS_JWT_SECRET=${JWT_SECRET}
2828+PDS_ADMIN_PASSWORD=${ADMIN_PASSWORD}
2929+PDS_PLC_ROTATION_KEY_K256_PRIVATE_KEY_HEX=${ROTATION_KEY}
3030+PDS_DATA_DIRECTORY=${DATA_DIR}
3131+PDS_BLOBSTORE_DISK_LOCATION=${DATA_DIR}/blocks
3232+PDS_DID_PLC_URL=https://plc.directory
3333+PDS_BSKY_APP_VIEW_URL=https://api.bsky.app
3434+PDS_MOD_SERVICE_URL=https://mod.bsky.app
3535+PDS_REPORT_SERVICE_URL=https://mod.bsky.app
3636+PDS_CRAWLERS=https://bsky.network
3737+PDS_SERVICE_HANDLE_DOMAINS=.localhost
3838+PDS_DEV_MODE=true
3939+PDS_INVITE_REQUIRED=false
4040+PDS_PORT=3000
4141+LOG_LEVEL=info
4242+EOF
4343+4444+echo "pds.env written."
4545+echo ""
4646+echo "Admin password: ${ADMIN_PASSWORD}"
4747+echo "Save this somewhere — you'll need it for admin operations."
+68
scripts/nixtests/start.sh
···11+#!/usr/bin/env bash
22+# Starts the PDS via the nix-provided `pds` binary.
33+set -euo pipefail
44+55+SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
66+DATA_DIR="${SCRIPT_DIR}/pds-data"
77+PID_FILE="${DATA_DIR}/pds.pid"
88+LOG_FILE="${DATA_DIR}/pds.log"
99+ENV_FILE="${SCRIPT_DIR}/pds.env"
1010+1111+# run setup if pds.env doesn't exist yet
1212+if [ ! -f "${ENV_FILE}" ]; then
1313+ echo "No pds.env found, running setup.sh first..."
1414+ bash "${SCRIPT_DIR}/setup.sh"
1515+fi
1616+1717+# check for already-running process
1818+if [ -f "${PID_FILE}" ]; then
1919+ OLD_PID=$(cat "${PID_FILE}")
2020+ if kill -0 "${OLD_PID}" 2>/dev/null; then
2121+ echo "PDS is already running (PID ${OLD_PID})."
2222+ exit 0
2323+ else
2424+ echo "Stale PID file found, cleaning up."
2525+ rm -f "${PID_FILE}"
2626+ fi
2727+fi
2828+2929+mkdir -p "${DATA_DIR}"
3030+3131+# export env vars from pds.env
3232+set -a
3333+source "${ENV_FILE}"
3434+set +a
3535+3636+echo "Starting PDS..."
3737+3838+# start pds in background
3939+pds > "${LOG_FILE}" 2>&1 &
4040+PDS_PID=$!
4141+echo "${PDS_PID}" > "${PID_FILE}"
4242+4343+echo "PDS started (PID ${PDS_PID}), waiting for health check..."
4444+4545+# health-check loop: 30s timeout
4646+TIMEOUT=30
4747+ELAPSED=0
4848+while [ "${ELAPSED}" -lt "${TIMEOUT}" ]; do
4949+ # make sure the process is still alive
5050+ if ! kill -0 "${PDS_PID}" 2>/dev/null; then
5151+ echo "PDS process died. Last log lines:"
5252+ tail -20 "${LOG_FILE}"
5353+ rm -f "${PID_FILE}"
5454+ exit 1
5555+ fi
5656+5757+ if curl -sf "http://localhost:${PDS_PORT:-3000}/xrpc/_health" > /dev/null 2>&1; then
5858+ echo "PDS is healthy (http://localhost:${PDS_PORT:-3000})."
5959+ exit 0
6060+ fi
6161+6262+ sleep 1
6363+ ELAPSED=$((ELAPSED + 1))
6464+done
6565+6666+echo "Health check timed out after ${TIMEOUT}s. Last log lines:"
6767+tail -20 "${LOG_FILE}"
6868+exit 1
+41
scripts/nixtests/stop.sh
···11+#!/usr/bin/env bash
22+# Stops the PDS via PID file.
33+set -euo pipefail
44+55+SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
66+PID_FILE="${SCRIPT_DIR}/pds-data/pds.pid"
77+88+if [ ! -f "${PID_FILE}" ]; then
99+ echo "No PID file found — PDS is not running."
1010+ exit 0
1111+fi
1212+1313+PDS_PID=$(cat "${PID_FILE}")
1414+1515+if ! kill -0 "${PDS_PID}" 2>/dev/null; then
1616+ echo "Process ${PDS_PID} is not running. Cleaning up stale PID file."
1717+ rm -f "${PID_FILE}"
1818+ exit 0
1919+fi
2020+2121+echo "Stopping PDS (PID ${PDS_PID})..."
2222+kill "${PDS_PID}"
2323+2424+# wait up to 10s for graceful shutdown
2525+TIMEOUT=10
2626+ELAPSED=0
2727+while [ "${ELAPSED}" -lt "${TIMEOUT}" ]; do
2828+ if ! kill -0 "${PDS_PID}" 2>/dev/null; then
2929+ echo "PDS stopped."
3030+ rm -f "${PID_FILE}"
3131+ exit 0
3232+ fi
3333+ sleep 1
3434+ ELAPSED=$((ELAPSED + 1))
3535+done
3636+3737+# force kill
3838+echo "Graceful shutdown timed out, sending SIGKILL..."
3939+kill -9 "${PDS_PID}" 2>/dev/null || true
4040+rm -f "${PID_FILE}"
4141+echo "PDS killed."
+47
scripts/pds-dev/README.md
···11+# Local PDS for development
22+33+Scripts for running an AT Protocol PDS server locally via Docker, used for integration testing of `pds-git-remote`.
44+55+## Quick start
66+77+```bash
88+# start the PDS (generates secrets on first run)
99+./start.sh
1010+1111+# create a test account
1212+./create-account.sh
1313+1414+# get an access token
1515+./login.sh
1616+1717+# or pipe the token directly
1818+TOKEN=$(./login.sh)
1919+```
2020+2121+## Scripts
2222+2323+| Script | Purpose |
2424+|--------|---------|
2525+| `start.sh` | Start PDS (runs `setup.sh` automatically if needed) |
2626+| `stop.sh` | Stop PDS |
2727+| `reset.sh` | Stop PDS and wipe all data |
2828+| `setup.sh` | Generate secrets and write `pds.env` (called by `start.sh`) |
2929+| `create-account.sh [handle] [password]` | Create a test account |
3030+| `login.sh [handle] [password]` | Get an access token |
3131+3232+## Defaults
3333+3434+- PDS URL: `http://localhost:3000`
3535+- Test account: `test.localhost` / `test-password-123`
3636+- Override PDS URL with `PDS_URL` env var
3737+3838+## Health check
3939+4040+```bash
4141+curl http://localhost:3000/xrpc/_health
4242+```
4343+4444+## Files (gitignored)
4545+4646+- `pds.env` — generated secrets
4747+- `pds-data/` — PDS data directory (SQLite, blobs)
···11+#!/usr/bin/env bash
22+# Logs in to the local PDS and prints the access token.
33+#
44+# Usage:
55+# ./login.sh # defaults: test.localhost / test-password-123
66+# ./login.sh myhandle.localhost mypass
77+#
88+# The access token is printed on its own line for piping:
99+# TOKEN=$(./login.sh)
1010+set -euo pipefail
1111+1212+HANDLE="${1:-alice.test}"
1313+PASSWORD="${2:-test-password-123}"
1414+PDS_URL="${PDS_URL:-http://localhost:3000}"
1515+1616+RESPONSE=$(curl -s -X POST \
1717+ -H "Content-Type: application/json" \
1818+ -d "{
1919+ \"identifier\": \"${HANDLE}\",
2020+ \"password\": \"${PASSWORD}\"
2121+ }" \
2222+ "${PDS_URL}/xrpc/com.atproto.server.createSession")
2323+2424+# check for error
2525+if echo "${RESPONSE}" | grep -q '"error"'; then
2626+ echo "Login failed:" >&2
2727+ echo "${RESPONSE}" | python3 -m json.tool 2>/dev/null >&2 || echo "${RESPONSE}" >&2
2828+ exit 1
2929+fi
3030+3131+# if stdout is a terminal, print labels; otherwise just the token
3232+if [ -t 1 ]; then
3333+ DID=$(echo "${RESPONSE}" | python3 -c "import sys,json; print(json.load(sys.stdin)['did'])" 2>/dev/null || echo "unknown")
3434+ ACCESS_JWT=$(echo "${RESPONSE}" | python3 -c "import sys,json; print(json.load(sys.stdin)['accessJwt'])" 2>/dev/null || echo "unknown")
3535+ echo "Logged in as ${HANDLE} (${DID})"
3636+ echo ""
3737+ echo "Access token:"
3838+ echo " ${ACCESS_JWT}"
3939+else
4040+ # piped — just print the token
4141+ echo "${RESPONSE}" | python3 -c "import sys,json; print(json.load(sys.stdin)['accessJwt'])"
4242+fi
+12
scripts/pds-dev/reset.sh
···11+#!/usr/bin/env bash
22+# Stops the PDS and wipes all data (accounts, blobs, records).
33+# You'll need to run start.sh and create-account.sh again afterward.
44+set -euo pipefail
55+66+SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
77+88+echo "Stopping PDS and wiping all data..."
99+docker compose -f "${SCRIPT_DIR}/compose.yaml" down -v 2>/dev/null || true
1010+rm -rf "${SCRIPT_DIR}/pds-data"
1111+rm -f "${SCRIPT_DIR}/pds.env"
1212+echo "Done. Run start.sh to set up a fresh PDS."
+52
scripts/pds-dev/run-e2e.sh
···11+#!/usr/bin/env bash
22+# Runs E2E tests against a fresh local PDS.
33+#
44+# Usage:
55+# ./run-e2e.sh # start PDS, run tests, stop PDS
66+# ./run-e2e.sh --keep # keep PDS running after tests
77+set -euo pipefail
88+99+SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
1010+CRATE_DIR="$(cd "${SCRIPT_DIR}/../.." && pwd)"
1111+KEEP=false
1212+1313+for arg in "$@"; do
1414+ if [ "$arg" = "--keep" ]; then
1515+ KEEP=true
1616+ fi
1717+done
1818+1919+# clean up PDS on exit unless --keep
2020+cleanup() {
2121+ if [ "$KEEP" = false ]; then
2222+ echo ""
2323+ echo "Stopping PDS..."
2424+ bash "${SCRIPT_DIR}/stop.sh"
2525+ else
2626+ echo ""
2727+ echo "PDS left running (--keep). Stop with: bash ${SCRIPT_DIR}/stop.sh"
2828+ fi
2929+}
3030+trap cleanup EXIT
3131+3232+# reset to clean slate
3333+echo "=== Resetting PDS ==="
3434+bash "${SCRIPT_DIR}/reset.sh"
3535+3636+# start PDS
3737+echo ""
3838+echo "=== Starting PDS ==="
3939+bash "${SCRIPT_DIR}/start.sh"
4040+4141+# create test account
4242+echo ""
4343+echo "=== Creating test account ==="
4444+bash "${SCRIPT_DIR}/create-account.sh"
4545+4646+# run E2E tests
4747+echo ""
4848+echo "=== Running E2E tests ==="
4949+cargo test -p pds-git-remote --features e2e -- --test-threads=1
5050+5151+echo ""
5252+echo "=== All E2E tests passed ==="
+49
scripts/pds-dev/setup.sh
···11+#!/usr/bin/env bash
22+# Generates secrets and writes pds.env for local development.
33+# Run once before starting the PDS for the first time.
44+set -euo pipefail
55+66+SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
77+ENV_FILE="${SCRIPT_DIR}/pds.env"
88+DATA_DIR="${SCRIPT_DIR}/pds-data"
99+1010+if [ -f "${ENV_FILE}" ]; then
1111+ echo "pds.env already exists, skipping setup."
1212+ echo "Run reset.sh first if you want a fresh environment."
1313+ exit 0
1414+fi
1515+1616+echo "Generating secrets..."
1717+1818+JWT_SECRET=$(openssl rand --hex 16)
1919+ADMIN_PASSWORD=$(openssl rand --hex 16)
2020+ROTATION_KEY=$(openssl ecparam -name secp256k1 -genkey -noout -outform DER 2>/dev/null | \
2121+ tail -c +8 | head -c 32 | xxd -p -c 32)
2222+2323+mkdir -p "${DATA_DIR}"
2424+2525+cat > "${ENV_FILE}" <<EOF
2626+PDS_HOSTNAME=pds.test
2727+PDS_JWT_SECRET=${JWT_SECRET}
2828+PDS_ADMIN_PASSWORD=${ADMIN_PASSWORD}
2929+PDS_PLC_ROTATION_KEY_K256_PRIVATE_KEY_HEX=${ROTATION_KEY}
3030+PDS_DATA_DIRECTORY=/pds
3131+PDS_BLOBSTORE_DISK_LOCATION=/pds/blocks
3232+PDS_DID_PLC_URL=https://plc.directory
3333+PDS_BSKY_APP_VIEW_URL=https://api.bsky.app
3434+PDS_BSKY_APP_VIEW_DID=did:web:api.bsky.app
3535+PDS_MOD_SERVICE_URL=https://mod.bsky.app
3636+PDS_MOD_SERVICE_DID=did:plc:ar7c4by46qjdydhdevvrndac
3737+PDS_REPORT_SERVICE_URL=https://mod.bsky.app
3838+PDS_REPORT_SERVICE_DID=did:plc:ar7c4by46qjdydhdevvrndac
3939+PDS_CRAWLERS=https://bsky.network
4040+PDS_SERVICE_HANDLE_DOMAINS=.test
4141+PDS_DEV_MODE=true
4242+PDS_INVITE_REQUIRED=false
4343+LOG_LEVEL=info
4444+EOF
4545+4646+echo "pds.env written."
4747+echo ""
4848+echo "Admin password: ${ADMIN_PASSWORD}"
4949+echo "Save this somewhere — you'll need it for admin operations."
+30
scripts/pds-dev/start.sh
···11+#!/usr/bin/env bash
22+# Starts the local PDS server via Docker Compose.
33+# Runs setup.sh automatically if pds.env doesn't exist yet.
44+set -euo pipefail
55+66+SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
77+88+# run setup if needed
99+if [ ! -f "${SCRIPT_DIR}/pds.env" ]; then
1010+ echo "No pds.env found, running setup..."
1111+ bash "${SCRIPT_DIR}/setup.sh"
1212+ echo ""
1313+fi
1414+1515+echo "Starting PDS..."
1616+docker compose -f "${SCRIPT_DIR}/compose.yaml" up -d
1717+1818+# wait for health check
1919+echo "Waiting for PDS to be ready..."
2020+for i in $(seq 1 30); do
2121+ if curl -sf http://localhost:3000/xrpc/_health > /dev/null 2>&1; then
2222+ echo "PDS is ready at http://localhost:3000"
2323+ exit 0
2424+ fi
2525+ sleep 1
2626+done
2727+2828+echo "ERROR: PDS did not become healthy within 30 seconds."
2929+echo "Check logs with: docker compose -f ${SCRIPT_DIR}/compose.yaml logs"
3030+exit 1
+7
scripts/pds-dev/stop.sh
···11+#!/usr/bin/env bash
22+# Stops the local PDS server.
33+set -euo pipefail
44+55+SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
66+docker compose -f "${SCRIPT_DIR}/compose.yaml" down
77+echo "PDS stopped."
+195
src/auth.rs
···11+//! Credential storage and authentication resolution.
22+//!
33+//! Stores AT Protocol credentials at `~/.config/pds-git-remote/auth.json`.
44+//! Provides `resolve_auth` for the remote helper to find credentials from
55+//! env vars or stored config.
66+77+use std::collections::HashMap;
88+use std::path::PathBuf;
99+1010+use serde::{Deserialize, Serialize};
1111+1212+use crate::pds_client::PdsClient;
1313+1414+/// Stored credential for a single AT Protocol account.
1515+#[derive(Debug, Clone, Serialize, Deserialize)]
1616+pub struct StoredCredential {
1717+ pub pds_url: String,
1818+ pub handle: String,
1919+ pub did: String,
2020+ pub access_jwt: String,
2121+ pub refresh_jwt: String,
2222+}
2323+2424+/// Top-level auth config file.
2525+#[derive(Debug, Clone, Serialize, Deserialize, Default)]
2626+pub struct AuthConfig {
2727+ #[serde(default)]
2828+ pub credentials: HashMap<String, StoredCredential>,
2929+}
3030+3131+/// Resolved auth info needed for push operations.
3232+#[derive(Debug, Clone)]
3333+pub struct ResolvedAuth {
3434+ pub pds_url: String,
3535+ pub did: String,
3636+ pub access_jwt: String,
3737+}
3838+3939+/// Returns the path to the auth config file.
4040+fn config_path() -> PathBuf {
4141+ let home = std::env::var("HOME").unwrap_or_else(|_| ".".to_string());
4242+ PathBuf::from(home)
4343+ .join(".config")
4444+ .join("pds-git-remote")
4545+ .join("auth.json")
4646+}
4747+4848+/// Loads the auth config from disk, returning a default if the file is missing.
4949+pub fn load_config() -> Result<AuthConfig, String> {
5050+ let path = config_path();
5151+ if !path.exists() {
5252+ return Ok(AuthConfig::default());
5353+ }
5454+5555+ let data =
5656+ std::fs::read_to_string(&path).map_err(|e| format!("failed to read auth config: {}", e))?;
5757+5858+ serde_json::from_str(&data).map_err(|e| format!("failed to parse auth config: {}", e))
5959+}
6060+6161+/// Saves the auth config to disk, creating parent directories if needed.
6262+pub fn save_config(config: &AuthConfig) -> Result<(), String> {
6363+ let path = config_path();
6464+6565+ if let Some(parent) = path.parent() {
6666+ std::fs::create_dir_all(parent)
6767+ .map_err(|e| format!("failed to create config directory: {}", e))?;
6868+ }
6969+7070+ let data = serde_json::to_string_pretty(config)
7171+ .map_err(|e| format!("failed to serialize auth config: {}", e))?;
7272+7373+ std::fs::write(&path, data).map_err(|e| format!("failed to write auth config: {}", e))
7474+}
7575+7676+/// Looks up a stored credential by handle.
7777+pub fn get_credential(handle: &str) -> Result<Option<StoredCredential>, String> {
7878+ let config = load_config()?;
7979+ Ok(config.credentials.get(handle).cloned())
8080+}
8181+8282+/// Logs in via createSession and stores the credential.
8383+pub async fn login_and_store(
8484+ pds_url: &str,
8585+ handle: &str,
8686+ password: &str,
8787+) -> Result<StoredCredential, String> {
8888+ let mut client = PdsClient::new(pds_url);
8989+ let session = client.login(handle, password).await?;
9090+9191+ let cred = StoredCredential {
9292+ pds_url: pds_url.to_string(),
9393+ handle: session.handle.clone(),
9494+ did: session.did.clone(),
9595+ access_jwt: session.access_jwt,
9696+ refresh_jwt: session.refresh_jwt,
9797+ };
9898+9999+ // save to config
100100+ let mut config = load_config()?;
101101+ config.credentials.insert(handle.to_string(), cred.clone());
102102+ save_config(&config)?;
103103+104104+ Ok(cred)
105105+}
106106+107107+/// Resolves authentication for a push operation.
108108+///
109109+/// Checks sources in priority order:
110110+/// 1. `PDS_ACCESS_TOKEN` + `PDS_DID` env vars (direct, no network)
111111+/// 2. `PDS_HANDLE` + `PDS_PASSWORD` env vars (login on the fly)
112112+/// 3. Stored credential from auth.json
113113+/// 4. Error: not logged in
114114+pub async fn resolve_auth(handle: &str, pds_url: &str) -> Result<ResolvedAuth, String> {
115115+ // 1. direct token from env
116116+ if let (Ok(token), Ok(did)) = (std::env::var("PDS_ACCESS_TOKEN"), std::env::var("PDS_DID")) {
117117+ return Ok(ResolvedAuth {
118118+ pds_url: pds_url.to_string(),
119119+ did,
120120+ access_jwt: token,
121121+ });
122122+ }
123123+124124+ // 2. handle + password from env → login on the fly
125125+ if let (Ok(env_handle), Ok(password)) =
126126+ (std::env::var("PDS_HANDLE"), std::env::var("PDS_PASSWORD"))
127127+ {
128128+ let cred = login_and_store(pds_url, &env_handle, &password).await?;
129129+ return Ok(ResolvedAuth {
130130+ pds_url: cred.pds_url,
131131+ did: cred.did,
132132+ access_jwt: cred.access_jwt,
133133+ });
134134+ }
135135+136136+ // 3. stored credential
137137+ if let Some(cred) = get_credential(handle)? {
138138+ return Ok(ResolvedAuth {
139139+ pds_url: cred.pds_url,
140140+ did: cred.did,
141141+ access_jwt: cred.access_jwt,
142142+ });
143143+ }
144144+145145+ // 4. no credentials found
146146+ Err(format!(
147147+ "not logged in for '{}' — run: git-remote-pds auth login --handle {}",
148148+ handle, handle
149149+ ))
150150+}
151151+152152+/// Removes a stored credential by handle.
153153+pub fn logout(handle: &str) -> Result<bool, String> {
154154+ let mut config = load_config()?;
155155+ let removed = config.credentials.remove(handle).is_some();
156156+ if removed {
157157+ save_config(&config)?;
158158+ }
159159+ Ok(removed)
160160+}
161161+162162+#[cfg(test)]
163163+mod tests {
164164+ use super::*;
165165+166166+ #[test]
167167+ fn auth_config_round_trip() {
168168+ let mut config = AuthConfig::default();
169169+ config.credentials.insert(
170170+ "alice.test".to_string(),
171171+ StoredCredential {
172172+ pds_url: "http://localhost:3000".to_string(),
173173+ handle: "alice.test".to_string(),
174174+ did: "did:plc:abc123".to_string(),
175175+ access_jwt: "jwt-access".to_string(),
176176+ refresh_jwt: "jwt-refresh".to_string(),
177177+ },
178178+ );
179179+180180+ let json = serde_json::to_string(&config).unwrap();
181181+ let parsed: AuthConfig = serde_json::from_str(&json).unwrap();
182182+183183+ assert_eq!(parsed.credentials.len(), 1);
184184+ let cred = parsed.credentials.get("alice.test").unwrap();
185185+ assert_eq!(cred.did, "did:plc:abc123");
186186+ assert_eq!(cred.handle, "alice.test");
187187+ }
188188+189189+ #[test]
190190+ fn empty_config_deserializes() {
191191+ let json = "{}";
192192+ let config: AuthConfig = serde_json::from_str(json).unwrap();
193193+ assert!(config.credentials.is_empty());
194194+ }
195195+}
+315
src/fetch.rs
···11+//! Fetch/clone flow: download bundle chain from PDS and apply to local repo.
22+//!
33+//! Reads the remote state record, downloads bundles as blobs,
44+//! reassembles chunked bundles, applies them via `git bundle unbundle`,
55+//! and updates local refs to match the remote state.
66+77+use std::path::Path;
88+99+use crate::bundle::apply_bundle;
1010+use crate::chunk::reassemble_chunks;
1111+use crate::pds_client::PdsClient;
1212+use crate::types::{BundleEntry, COLLECTION, RepoState};
1313+1414+/// Result of a fetch or clone operation.
1515+#[derive(Debug)]
1616+pub enum FetchResult {
1717+ /// bundles were downloaded and applied
1818+ Applied {
1919+ /// number of bundles applied
2020+ bundles_applied: usize,
2121+ /// total bytes downloaded
2222+ bytes_downloaded: u64,
2323+ },
2424+ /// local refs already match remote — nothing to do
2525+ AlreadyUpToDate,
2626+}
2727+2828+/// Clones a repository from PDS into a local git repo.
2929+///
3030+/// The repo at `repo_path` should already be initialized (bare or working).
3131+/// Downloads all bundles in the chain from oldest to newest, applies each,
3232+/// then updates local refs and checks out the default branch (if working tree).
3333+pub async fn clone_repo(
3434+ client: &PdsClient,
3535+ did: &str,
3636+ repo_name: &str,
3737+ repo_path: &Path,
3838+) -> Result<FetchResult, String> {
3939+ // read remote state
4040+ let state = read_remote_state(client, did, repo_name).await?;
4141+4242+ if state.bundles.is_empty() {
4343+ return Err("remote state has no bundles".to_string());
4444+ }
4545+4646+ // download and apply all bundles in order (oldest first)
4747+ let (applied, downloaded) = download_and_apply(client, did, repo_path, &state.bundles).await?;
4848+4949+ // update local refs to match remote state
5050+ update_local_refs(repo_path, &state).await?;
5151+5252+ // checkout default branch if this is a working tree
5353+ checkout_default_branch(repo_path, &state).await?;
5454+5555+ Ok(FetchResult::Applied {
5656+ bundles_applied: applied,
5757+ bytes_downloaded: downloaded,
5858+ })
5959+}
6060+6161+/// Fetches new commits from PDS into an existing local repo.
6262+///
6363+/// Compares local refs against the remote state and downloads only
6464+/// bundles that contain new commits. Skips bundles whose tips are
6565+/// already present in the local repo.
6666+pub async fn fetch_repo(
6767+ client: &PdsClient,
6868+ did: &str,
6969+ repo_name: &str,
7070+ repo_path: &Path,
7171+) -> Result<FetchResult, String> {
7272+ // read remote state
7373+ let state = read_remote_state(client, did, repo_name).await?;
7474+7575+ // find which bundles we still need
7676+ let new_bundles = find_new_bundles(repo_path, &state.bundles).await?;
7777+7878+ if new_bundles.is_empty() {
7979+ return Ok(FetchResult::AlreadyUpToDate);
8080+ }
8181+8282+ // download and apply only the new bundles
8383+ let (applied, downloaded) = download_and_apply(client, did, repo_path, &new_bundles).await?;
8484+8585+ // update local refs to match remote state
8686+ update_local_refs(repo_path, &state).await?;
8787+8888+ // update the working tree if not a bare repo
8989+ update_working_tree(repo_path).await?;
9090+9191+ Ok(FetchResult::Applied {
9292+ bundles_applied: applied,
9393+ bytes_downloaded: downloaded,
9494+ })
9595+}
9696+9797+/// Reads and parses the remote state record from PDS.
9898+pub async fn read_remote_state(
9999+ client: &PdsClient,
100100+ did: &str,
101101+ repo_name: &str,
102102+) -> Result<RepoState, String> {
103103+ let record = client
104104+ .get_record(did, COLLECTION, repo_name)
105105+ .await?
106106+ .ok_or_else(|| format!("no state record found for {}", repo_name))?;
107107+108108+ serde_json::from_value(record.value).map_err(|e| format!("failed to parse remote state: {}", e))
109109+}
110110+111111+/// Downloads and applies a slice of bundle entries to the local repo.
112112+///
113113+/// Returns (bundles_applied, total_bytes_downloaded).
114114+pub async fn download_and_apply(
115115+ client: &PdsClient,
116116+ did: &str,
117117+ repo_path: &Path,
118118+ bundles: &[BundleEntry],
119119+) -> Result<(usize, u64), String> {
120120+ let mut total_bytes: u64 = 0;
121121+122122+ for (i, entry) in bundles.iter().enumerate() {
123123+ tracing::info!(
124124+ "downloading bundle {}/{} ({} part(s))",
125125+ i + 1,
126126+ bundles.len(),
127127+ entry.parts.len()
128128+ );
129129+130130+ // download all parts of this bundle
131131+ let bundle_data = download_bundle_parts(client, did, entry).await?;
132132+ total_bytes += bundle_data.len() as u64;
133133+134134+ // apply the bundle
135135+ apply_bundle(repo_path, &bundle_data).await?;
136136+ }
137137+138138+ Ok((bundles.len(), total_bytes))
139139+}
140140+141141+/// Downloads and reassembles the parts of a single bundle entry.
142142+///
143143+/// Most bundles have a single part. Chunked bundles (>40MB) have
144144+/// multiple parts that are concatenated in order.
145145+async fn download_bundle_parts(
146146+ client: &PdsClient,
147147+ did: &str,
148148+ entry: &BundleEntry,
149149+) -> Result<Vec<u8>, String> {
150150+ if entry.parts.len() == 1 {
151151+ // common case: single-part bundle
152152+ return client.get_blob(did, entry.parts[0].cid()).await;
153153+ }
154154+155155+ // multi-part: download each chunk and reassemble
156156+ let mut parts = Vec::with_capacity(entry.parts.len());
157157+ for part in &entry.parts {
158158+ let data = client.get_blob(did, part.cid()).await?;
159159+ parts.push(data);
160160+ }
161161+162162+ Ok(reassemble_chunks(&parts))
163163+}
164164+165165+/// Determines which bundles the local repo doesn't have yet.
166166+///
167167+/// Walks the bundle chain from oldest to newest and skips entries
168168+/// whose tip commits are already present in the local repo.
169169+pub async fn find_new_bundles<'a>(
170170+ repo_path: &Path,
171171+ bundles: &'a [BundleEntry],
172172+) -> Result<Vec<BundleEntry>, String> {
173173+ let mut new_bundles = Vec::new();
174174+175175+ for entry in bundles {
176176+ // a bundle is "already applied" if all its tips exist locally
177177+ let all_tips_present = if entry.tips.is_empty() {
178178+ false
179179+ } else {
180180+ let mut all_present = true;
181181+ for tip in &entry.tips {
182182+ if !commit_exists(repo_path, tip).await? {
183183+ all_present = false;
184184+ break;
185185+ }
186186+ }
187187+ all_present
188188+ };
189189+190190+ if !all_tips_present {
191191+ new_bundles.push(entry.clone());
192192+ }
193193+ }
194194+195195+ Ok(new_bundles)
196196+}
197197+198198+/// Checks if a commit SHA exists in the local repository.
199199+async fn commit_exists(repo_path: &Path, sha: &str) -> Result<bool, String> {
200200+ let output = tokio::process::Command::new("git")
201201+ .args(["cat-file", "-t", sha])
202202+ .current_dir(repo_path)
203203+ .output()
204204+ .await
205205+ .map_err(|e| format!("failed to run git cat-file: {}", e))?;
206206+207207+ Ok(output.status.success())
208208+}
209209+210210+/// Updates local refs to match the remote state record.
211211+///
212212+/// Uses `git update-ref` for each ref in the state.
213213+async fn update_local_refs(repo_path: &Path, state: &RepoState) -> Result<(), String> {
214214+ for git_ref in &state.refs {
215215+ let output = tokio::process::Command::new("git")
216216+ .args(["update-ref", &git_ref.name, &git_ref.sha])
217217+ .current_dir(repo_path)
218218+ .output()
219219+ .await
220220+ .map_err(|e| format!("failed to run git update-ref: {}", e))?;
221221+222222+ if !output.status.success() {
223223+ let stderr = String::from_utf8_lossy(&output.stderr);
224224+ return Err(format!(
225225+ "git update-ref {} {} failed: {}",
226226+ git_ref.name,
227227+ git_ref.sha,
228228+ stderr.trim()
229229+ ));
230230+ }
231231+ }
232232+233233+ Ok(())
234234+}
235235+236236+/// Checks out the default branch if the repo has a working tree.
237237+///
238238+/// Picks the first ref that looks like a main branch (main, master),
239239+/// or falls back to the first ref in the state.
240240+async fn checkout_default_branch(repo_path: &Path, state: &RepoState) -> Result<(), String> {
241241+ // skip checkout for bare repos
242242+ if is_bare_repo(repo_path).await? {
243243+ return Ok(());
244244+ }
245245+246246+ if state.refs.is_empty() {
247247+ return Ok(());
248248+ }
249249+250250+ // find the best default branch
251251+ let default_ref = state
252252+ .refs
253253+ .iter()
254254+ .find(|r| r.name == "refs/heads/main")
255255+ .or_else(|| state.refs.iter().find(|r| r.name == "refs/heads/master"))
256256+ .unwrap_or(&state.refs[0]);
257257+258258+ // extract branch name from full ref
259259+ let branch = default_ref
260260+ .name
261261+ .strip_prefix("refs/heads/")
262262+ .unwrap_or(&default_ref.name);
263263+264264+ let output = tokio::process::Command::new("git")
265265+ .args(["checkout", branch])
266266+ .current_dir(repo_path)
267267+ .output()
268268+ .await
269269+ .map_err(|e| format!("failed to run git checkout: {}", e))?;
270270+271271+ if !output.status.success() {
272272+ let stderr = String::from_utf8_lossy(&output.stderr);
273273+ return Err(format!("git checkout {} failed: {}", branch, stderr.trim()));
274274+ }
275275+276276+ Ok(())
277277+}
278278+279279+/// Updates the working tree to match the current branch HEAD.
280280+///
281281+/// After `update_local_refs` moves branch pointers forward, the working
282282+/// tree is stale. This resets it to match. Skips bare repos.
283283+async fn update_working_tree(repo_path: &Path) -> Result<(), String> {
284284+ if is_bare_repo(repo_path).await? {
285285+ return Ok(());
286286+ }
287287+288288+ // reset the working tree to match the updated branch tip
289289+ let output = tokio::process::Command::new("git")
290290+ .args(["reset", "--hard", "HEAD"])
291291+ .current_dir(repo_path)
292292+ .output()
293293+ .await
294294+ .map_err(|e| format!("failed to run git reset: {}", e))?;
295295+296296+ if !output.status.success() {
297297+ let stderr = String::from_utf8_lossy(&output.stderr);
298298+ return Err(format!("git reset --hard HEAD failed: {}", stderr.trim()));
299299+ }
300300+301301+ Ok(())
302302+}
303303+304304+/// Returns true if the repo at the given path is a bare repository.
305305+async fn is_bare_repo(repo_path: &Path) -> Result<bool, String> {
306306+ let output = tokio::process::Command::new("git")
307307+ .args(["rev-parse", "--is-bare-repository"])
308308+ .current_dir(repo_path)
309309+ .output()
310310+ .await
311311+ .map_err(|e| format!("failed to run git rev-parse --is-bare-repository: {}", e))?;
312312+313313+ let stdout = String::from_utf8_lossy(&output.stdout);
314314+ Ok(stdout.trim() == "true")
315315+}
+3
src/lib.rs
···44//! as chains of incremental git bundles uploaded as PDS blobs, tracked
55//! by a single mutable state record.
6677+pub mod auth;
78pub mod bundle;
89pub mod chunk;
1010+pub mod fetch;
911pub mod identity;
1012pub mod pds_client;
1313+pub mod push;
1114pub mod types;
+152
src/main.rs
···11+//! git-remote-pds: PDS-backed git remote helper.
22+//!
33+//! When invoked by git as a remote helper (3 args, not "auth"):
44+//! git-remote-pds <remote-name> <url>
55+//!
66+//! When invoked directly for credential management:
77+//! git-remote-pds auth login --pds-url <url> --handle <handle>
88+//! git-remote-pds auth status
99+//! git-remote-pds auth logout --handle <handle>
1010+1111+mod remote_helper;
1212+1313+use clap::{Parser, Subcommand};
1414+1515+#[derive(Parser)]
1616+#[command(name = "git-remote-pds", about = "PDS-backed git remote helper")]
1717+struct Cli {
1818+ #[command(subcommand)]
1919+ command: CliCommand,
2020+}
2121+2222+#[derive(Subcommand)]
2323+enum CliCommand {
2424+ /// Manage authentication credentials
2525+ Auth {
2626+ #[command(subcommand)]
2727+ action: AuthAction,
2828+ },
2929+}
3030+3131+#[derive(Subcommand)]
3232+enum AuthAction {
3333+ /// Log in to a PDS and store credentials
3434+ Login {
3535+ /// PDS server URL
3636+ #[arg(long)]
3737+ pds_url: String,
3838+ /// AT Protocol handle
3939+ #[arg(long)]
4040+ handle: String,
4141+ },
4242+ /// Show stored credentials
4343+ Status,
4444+ /// Remove stored credentials for a handle
4545+ Logout {
4646+ /// AT Protocol handle to log out
4747+ #[arg(long)]
4848+ handle: String,
4949+ },
5050+}
5151+5252+#[tokio::main]
5353+async fn main() {
5454+ // initialize logging to stderr (stdout is reserved for protocol)
5555+ tracing_subscriber::fmt()
5656+ .with_writer(std::io::stderr)
5757+ .with_env_filter(tracing_subscriber::EnvFilter::from_default_env())
5858+ .init();
5959+6060+ let args: Vec<String> = std::env::args().collect();
6161+6262+ // detect remote helper mode: git invokes us as `git-remote-pds <name> <url>`
6363+ // with exactly 3 args and the second arg is not "auth"
6464+ if args.len() == 3 && args[1] != "auth" {
6565+ let remote_name = &args[1];
6666+ let url = &args[2];
6767+6868+ if let Err(e) = remote_helper::run(remote_name, url).await {
6969+ eprintln!("git-remote-pds: error: {}", e);
7070+ std::process::exit(1);
7171+ }
7272+ return;
7373+ }
7474+7575+ // CLI mode — parse with clap
7676+ let cli = Cli::parse();
7777+7878+ match cli.command {
7979+ CliCommand::Auth { action } => match action {
8080+ AuthAction::Login { pds_url, handle } => {
8181+ handle_login(&pds_url, &handle).await;
8282+ }
8383+ AuthAction::Status => {
8484+ handle_status();
8585+ }
8686+ AuthAction::Logout { handle } => {
8787+ handle_logout(&handle);
8888+ }
8989+ },
9090+ }
9191+}
9292+9393+/// Handles `auth login` — reads password from env or stdin, logs in, stores credential.
9494+async fn handle_login(pds_url: &str, handle: &str) {
9595+ // read password from PDS_PASSWORD env var or stdin
9696+ let password = match std::env::var("PDS_PASSWORD") {
9797+ Ok(pw) => pw,
9898+ Err(_) => {
9999+ eprint!("Password: ");
100100+ let mut pw = String::new();
101101+ std::io::stdin()
102102+ .read_line(&mut pw)
103103+ .expect("failed to read password");
104104+ pw.trim().to_string()
105105+ }
106106+ };
107107+108108+ match pds_git_remote::auth::login_and_store(pds_url, handle, &password).await {
109109+ Ok(cred) => {
110110+ eprintln!("logged in as {} ({})", cred.handle, cred.did);
111111+ }
112112+ Err(e) => {
113113+ eprintln!("login failed: {}", e);
114114+ std::process::exit(1);
115115+ }
116116+ }
117117+}
118118+119119+/// Handles `auth status` — prints stored credentials.
120120+fn handle_status() {
121121+ match pds_git_remote::auth::load_config() {
122122+ Ok(config) => {
123123+ if config.credentials.is_empty() {
124124+ eprintln!("no stored credentials");
125125+ return;
126126+ }
127127+ for (handle, cred) in &config.credentials {
128128+ eprintln!("{} ({}) @ {}", handle, cred.did, cred.pds_url);
129129+ }
130130+ }
131131+ Err(e) => {
132132+ eprintln!("failed to load config: {}", e);
133133+ std::process::exit(1);
134134+ }
135135+ }
136136+}
137137+138138+/// Handles `auth logout` — removes stored credential.
139139+fn handle_logout(handle: &str) {
140140+ match pds_git_remote::auth::logout(handle) {
141141+ Ok(true) => {
142142+ eprintln!("logged out {}", handle);
143143+ }
144144+ Ok(false) => {
145145+ eprintln!("no stored credential for {}", handle);
146146+ }
147147+ Err(e) => {
148148+ eprintln!("logout failed: {}", e);
149149+ std::process::exit(1);
150150+ }
151151+ }
152152+}
+287
src/push.rs
···11+//! Push flow: upload local commits to PDS as incremental bundles.
22+//!
33+//! Reads the current remote state, creates a bundle of new commits,
44+//! uploads it as blob(s), and updates the state record.
55+66+use std::path::Path;
77+88+use crate::bundle::{create_full_bundle, create_incremental_bundle};
99+use crate::chunk::{DEFAULT_CHUNK_SIZE, chunk_bytes};
1010+use crate::pds_client::PdsClient;
1111+use crate::types::{BundleEntry, COLLECTION, GitRef, RepoState};
1212+1313+/// Result of a push operation.
1414+#[derive(Debug)]
1515+pub enum PushResult {
1616+ /// new commits were pushed successfully
1717+ Pushed {
1818+ /// number of bundles uploaded (usually 1)
1919+ bundles_uploaded: usize,
2020+ /// total bytes uploaded
2121+ bytes_uploaded: u64,
2222+ },
2323+ /// local and remote refs match — nothing to do
2424+ AlreadyUpToDate,
2525+}
2626+2727+/// Pushes local commits to PDS.
2828+///
2929+/// Reads the remote state record, determines what's new, creates a
3030+/// bundle of the delta, uploads it, and updates the state record.
3131+/// On first push (no existing state), creates a full bundle.
3232+pub async fn push(
3333+ client: &PdsClient,
3434+ did: &str,
3535+ repo_name: &str,
3636+ repo_path: &Path,
3737+) -> Result<PushResult, String> {
3838+ // check local branches first (avoids hitting the network for empty repos)
3939+ let local_refs = get_local_refs(repo_path).await?;
4040+ if local_refs.is_empty() {
4141+ return Err("no local branches to push (is the repo empty?)".to_string());
4242+ }
4343+4444+ // read the current remote state (if any)
4545+ let existing = client.get_record(did, COLLECTION, repo_name).await?;
4646+4747+ let (remote_state, swap_cid) = match &existing {
4848+ Some(record) => {
4949+ let state: RepoState = serde_json::from_value(record.value.clone())
5050+ .map_err(|e| format!("failed to parse remote state: {}", e))?;
5151+ let cid = record.cid.clone();
5252+ (Some(state), cid)
5353+ }
5454+ None => (None, None),
5555+ };
5656+5757+ // determine if there's anything new to push
5858+ if let Some(ref state) = remote_state {
5959+ if refs_match(&local_refs, &state.refs) {
6060+ return Ok(PushResult::AlreadyUpToDate);
6161+ }
6262+6363+ // check for non-fast-forward
6464+ check_fast_forward(repo_path, &state.refs, &local_refs).await?;
6565+ }
6666+6767+ // create the bundle
6868+ let bundle = match &remote_state {
6969+ None => {
7070+ // first push — full bundle
7171+ tracing::info!("first push to {}, creating full bundle", repo_name);
7272+ create_full_bundle(repo_path).await?
7373+ }
7474+ Some(state) => {
7575+ // incremental — bundle since the last known tips
7676+ let since_commits: Vec<&str> = state.refs.iter().map(|r| r.sha.as_str()).collect();
7777+ let ref_names: Vec<&str> = local_refs.iter().map(|r| r.name.as_str()).collect();
7878+ tracing::info!(
7979+ "incremental push to {}, {} refs since {} prerequisite(s)",
8080+ repo_name,
8181+ ref_names.len(),
8282+ since_commits.len()
8383+ );
8484+ create_incremental_bundle(repo_path, &ref_names, &since_commits).await?
8585+ }
8686+ };
8787+8888+ // upload the bundle blob(s), chunking if needed
8989+ let chunks = chunk_bytes(&bundle.data, DEFAULT_CHUNK_SIZE);
9090+ let mut blob_refs = Vec::with_capacity(chunks.len());
9191+ let mut total_bytes: u64 = 0;
9292+9393+ for chunk in &chunks {
9494+ let blob_ref = client.upload_blob(chunk.to_vec()).await?;
9595+ total_bytes += blob_ref.size;
9696+ blob_refs.push(blob_ref);
9797+ }
9898+9999+ // build the new bundle entry
100100+ let now = chrono_now();
101101+ let entry = BundleEntry {
102102+ parts: blob_refs,
103103+ prerequisites: bundle.prerequisites,
104104+ tips: bundle.tips,
105105+ total_size: Some(total_bytes),
106106+ created_at: now.clone(),
107107+ };
108108+109109+ // build updated state
110110+ let mut bundles = match &remote_state {
111111+ Some(state) => state.bundles.clone(),
112112+ None => vec![],
113113+ };
114114+ bundles.push(entry);
115115+116116+ let new_state = RepoState {
117117+ name: Some(repo_name.to_string()),
118118+ refs: local_refs,
119119+ bundles,
120120+ updated_at: now,
121121+ };
122122+123123+ // write the updated state record
124124+ let record_value = serde_json::to_value(&new_state)
125125+ .map_err(|e| format!("failed to serialize state: {}", e))?;
126126+127127+ client
128128+ .put_record(did, COLLECTION, repo_name, record_value, swap_cid)
129129+ .await?;
130130+131131+ Ok(PushResult::Pushed {
132132+ bundles_uploaded: chunks.len(),
133133+ bytes_uploaded: total_bytes,
134134+ })
135135+}
136136+137137+/// Returns all local branch refs as GitRef entries.
138138+async fn get_local_refs(repo_path: &Path) -> Result<Vec<GitRef>, String> {
139139+ let output = tokio::process::Command::new("git")
140140+ .args([
141141+ "for-each-ref",
142142+ "--format=%(refname) %(objectname)",
143143+ "refs/heads/",
144144+ ])
145145+ .current_dir(repo_path)
146146+ .output()
147147+ .await
148148+ .map_err(|e| format!("failed to run git for-each-ref: {}", e))?;
149149+150150+ if !output.status.success() {
151151+ let stderr = String::from_utf8_lossy(&output.stderr);
152152+ return Err(format!("git for-each-ref failed: {}", stderr.trim()));
153153+ }
154154+155155+ let stdout = String::from_utf8_lossy(&output.stdout);
156156+ let refs: Vec<GitRef> = stdout
157157+ .lines()
158158+ .filter_map(|line| {
159159+ let parts: Vec<&str> = line.splitn(2, ' ').collect();
160160+ if parts.len() == 2 {
161161+ Some(GitRef::new(parts[0], parts[1]))
162162+ } else {
163163+ None
164164+ }
165165+ })
166166+ .collect();
167167+168168+ Ok(refs)
169169+}
170170+171171+/// Checks if local refs match remote refs exactly.
172172+fn refs_match(local: &[GitRef], remote: &[GitRef]) -> bool {
173173+ if local.len() != remote.len() {
174174+ return false;
175175+ }
176176+ for local_ref in local {
177177+ let found = remote
178178+ .iter()
179179+ .any(|r| r.name == local_ref.name && r.sha == local_ref.sha);
180180+ if !found {
181181+ return false;
182182+ }
183183+ }
184184+ true
185185+}
186186+187187+/// Checks that each remote ref's SHA is an ancestor of the corresponding local ref.
188188+///
189189+/// This ensures we're only doing fast-forward pushes. Non-fast-forward
190190+/// (force push) is rejected.
191191+async fn check_fast_forward(
192192+ repo_path: &Path,
193193+ remote_refs: &[GitRef],
194194+ local_refs: &[GitRef],
195195+) -> Result<(), String> {
196196+ for remote_ref in remote_refs {
197197+ // find corresponding local ref
198198+ let local = local_refs.iter().find(|r| r.name == remote_ref.name);
199199+ let Some(local) = local else {
200200+ // remote has a branch that local doesn't — that's a delete, reject for now
201201+ return Err(format!(
202202+ "non-fast-forward: remote has {} but local does not (branch deletion not supported)",
203203+ remote_ref.name
204204+ ));
205205+ };
206206+207207+ // skip if SHAs already match
208208+ if local.sha == remote_ref.sha {
209209+ continue;
210210+ }
211211+212212+ // check if remote SHA is ancestor of local SHA
213213+ let output = tokio::process::Command::new("git")
214214+ .args(["merge-base", "--is-ancestor", &remote_ref.sha, &local.sha])
215215+ .current_dir(repo_path)
216216+ .output()
217217+ .await
218218+ .map_err(|e| format!("failed to run git merge-base: {}", e))?;
219219+220220+ if !output.status.success() {
221221+ return Err(format!(
222222+ "non-fast-forward: {} would move from {} to {} (use force push to override)",
223223+ remote_ref.name, remote_ref.sha, local.sha
224224+ ));
225225+ }
226226+ }
227227+228228+ Ok(())
229229+}
230230+231231+/// Simple UTC timestamp without pulling in chrono crate.
232232+fn chrono_now() -> String {
233233+ std::process::Command::new("date")
234234+ .args(["-u", "+%Y-%m-%dT%H:%M:%SZ"])
235235+ .output()
236236+ .ok()
237237+ .and_then(|o| {
238238+ if o.status.success() {
239239+ Some(String::from_utf8_lossy(&o.stdout).trim().to_string())
240240+ } else {
241241+ None
242242+ }
243243+ })
244244+ .unwrap_or_else(|| "1970-01-01T00:00:00Z".to_string())
245245+}
246246+247247+#[cfg(test)]
248248+mod tests {
249249+ use super::*;
250250+251251+ #[test]
252252+ fn refs_match_identical() {
253253+ let a = vec![
254254+ GitRef::new("refs/heads/main", "abc123"),
255255+ GitRef::new("refs/heads/dev", "def456"),
256256+ ];
257257+ let b = vec![
258258+ GitRef::new("refs/heads/dev", "def456"),
259259+ GitRef::new("refs/heads/main", "abc123"),
260260+ ];
261261+ assert!(refs_match(&a, &b));
262262+ }
263263+264264+ #[test]
265265+ fn refs_match_different_sha() {
266266+ let a = vec![GitRef::new("refs/heads/main", "abc123")];
267267+ let b = vec![GitRef::new("refs/heads/main", "different")];
268268+ assert!(!refs_match(&a, &b));
269269+ }
270270+271271+ #[test]
272272+ fn refs_match_different_count() {
273273+ let a = vec![GitRef::new("refs/heads/main", "abc123")];
274274+ let b = vec![
275275+ GitRef::new("refs/heads/main", "abc123"),
276276+ GitRef::new("refs/heads/dev", "def456"),
277277+ ];
278278+ assert!(!refs_match(&a, &b));
279279+ }
280280+281281+ #[test]
282282+ fn refs_match_empty() {
283283+ let a: Vec<GitRef> = vec![];
284284+ let b: Vec<GitRef> = vec![];
285285+ assert!(refs_match(&a, &b));
286286+ }
287287+}
+277
src/remote_helper.rs
···11+//! Git remote helper protocol handler.
22+//!
33+//! Speaks git's remote helper protocol over stdin/stdout, translating
44+//! between standard git commands and the PDS-backed bundle storage.
55+//! All diagnostic output goes to stderr; stdout is protocol-only.
66+77+use std::path::PathBuf;
88+99+use pds_git_remote::auth;
1010+use pds_git_remote::fetch::{download_and_apply, find_new_bundles, read_remote_state};
1111+use pds_git_remote::identity;
1212+use pds_git_remote::pds_client::PdsClient;
1313+use pds_git_remote::push;
1414+1515+/// Parses a `pds://handle/repo-name` URL into (handle, repo_name).
1616+fn parse_pds_url(url: &str) -> Result<(String, String), String> {
1717+ let stripped = url
1818+ .strip_prefix("pds://")
1919+ .ok_or_else(|| format!("invalid PDS URL (expected pds://): {}", url))?;
2020+2121+ let (handle, repo_name) = stripped
2222+ .split_once('/')
2323+ .ok_or_else(|| format!("invalid PDS URL (expected pds://handle/repo): {}", url))?;
2424+2525+ if handle.is_empty() || repo_name.is_empty() {
2626+ return Err(format!("invalid PDS URL (empty handle or repo): {}", url));
2727+ }
2828+2929+ Ok((handle.to_string(), repo_name.to_string()))
3030+}
3131+3232+/// Resolves the PDS URL for a handle.
3333+///
3434+/// Checks the `PDS_URL` env var first (for local dev), otherwise does
3535+/// full AT Protocol identity resolution.
3636+async fn resolve_pds_url(handle: &str) -> Result<(String, String), String> {
3737+ // local dev override — skip identity resolution
3838+ if let Ok(pds_url) = std::env::var("PDS_URL") {
3939+ let did = identity::resolve_handle(handle, Some(&pds_url)).await?;
4040+ return Ok((pds_url, did));
4141+ }
4242+4343+ // full resolution: handle → DID → PDS endpoint
4444+ let resolved = identity::resolve_identity(handle, None, None).await?;
4545+ Ok((resolved.pds_url, resolved.did))
4646+}
4747+4848+/// Runs the remote helper protocol loop.
4949+///
5050+/// Called by main when git invokes us as `git-remote-pds <remote> <url>`.
5151+/// Reads commands from stdin, writes responses to stdout.
5252+pub async fn run(_remote_name: &str, url: &str) -> Result<(), String> {
5353+ let (handle, repo_name) = parse_pds_url(url)?;
5454+5555+ // read commands from stdin line by line
5656+ let stdin = tokio::io::stdin();
5757+ let reader = tokio::io::BufReader::new(stdin);
5858+5959+ use tokio::io::AsyncBufReadExt;
6060+ let mut lines = reader.lines();
6161+6262+ while let Some(line) = lines
6363+ .next_line()
6464+ .await
6565+ .map_err(|e| format!("failed to read stdin: {}", e))?
6666+ {
6767+ let line = line.trim().to_string();
6868+6969+ if line.is_empty() {
7070+ // empty line can be a terminator for batched commands, skip
7171+ continue;
7272+ }
7373+7474+ if line == "capabilities" {
7575+ print!("push\nfetch\noption\n\n");
7676+ continue;
7777+ }
7878+7979+ if let Some(rest) = line.strip_prefix("option ") {
8080+ let _ = rest; // we don't support any options
8181+ println!("unsupported");
8282+ continue;
8383+ }
8484+8585+ if line == "list" || line == "list for-push" {
8686+ handle_list(&handle, &repo_name).await?;
8787+ continue;
8888+ }
8989+9090+ if line.starts_with("fetch ") {
9191+ // batch: collect all fetch lines, then process
9292+ let mut fetch_lines = vec![line.clone()];
9393+ while let Some(next) = lines
9494+ .next_line()
9595+ .await
9696+ .map_err(|e| format!("failed to read stdin: {}", e))?
9797+ {
9898+ let next = next.trim().to_string();
9999+ if next.is_empty() {
100100+ break;
101101+ }
102102+ fetch_lines.push(next);
103103+ }
104104+ handle_fetch(&handle, &repo_name, &fetch_lines).await?;
105105+ continue;
106106+ }
107107+108108+ if line.starts_with("push ") {
109109+ // batch: collect all push lines, then process
110110+ let mut push_lines = vec![line.clone()];
111111+ while let Some(next) = lines
112112+ .next_line()
113113+ .await
114114+ .map_err(|e| format!("failed to read stdin: {}", e))?
115115+ {
116116+ let next = next.trim().to_string();
117117+ if next.is_empty() {
118118+ break;
119119+ }
120120+ push_lines.push(next);
121121+ }
122122+ handle_push(&handle, &repo_name, &push_lines).await?;
123123+ continue;
124124+ }
125125+126126+ // unknown command — log and continue
127127+ eprintln!("git-remote-pds: unknown command: {}", line);
128128+ }
129129+130130+ Ok(())
131131+}
132132+133133+/// Handles the `list` / `list for-push` command.
134134+///
135135+/// Reads the remote state record and outputs refs in the format git expects.
136136+async fn handle_list(handle: &str, repo_name: &str) -> Result<(), String> {
137137+ let (pds_url, did) = match resolve_pds_url(handle).await {
138138+ Ok(result) => result,
139139+ Err(_) => {
140140+ // no remote state yet — output empty list
141141+ println!();
142142+ return Ok(());
143143+ }
144144+ };
145145+146146+ let client = PdsClient::new(&pds_url);
147147+ let state = match read_remote_state(&client, &did, repo_name).await {
148148+ Ok(state) => state,
149149+ Err(_) => {
150150+ // no state record — empty repo
151151+ println!();
152152+ return Ok(());
153153+ }
154154+ };
155155+156156+ // output each ref
157157+ for git_ref in &state.refs {
158158+ println!("{} {}", git_ref.sha, git_ref.name);
159159+ }
160160+161161+ // output HEAD symref pointing to the default branch
162162+ let default_branch = state
163163+ .refs
164164+ .iter()
165165+ .find(|r| r.name == "refs/heads/main")
166166+ .or_else(|| state.refs.iter().find(|r| r.name == "refs/heads/master"))
167167+ .or(state.refs.first());
168168+169169+ if let Some(default) = default_branch {
170170+ println!("@{} HEAD", default.name);
171171+ }
172172+173173+ // blank line terminates list
174174+ println!();
175175+176176+ Ok(())
177177+}
178178+179179+/// Handles a batch of `fetch` commands.
180180+///
181181+/// Downloads and applies bundles (objects only — git handles ref updates).
182182+async fn handle_fetch(
183183+ handle: &str,
184184+ repo_name: &str,
185185+ _fetch_lines: &[String],
186186+) -> Result<(), String> {
187187+ let (pds_url, did) = resolve_pds_url(handle).await?;
188188+ let client = PdsClient::new(&pds_url);
189189+190190+ // read remote state
191191+ let state = read_remote_state(&client, &did, repo_name).await?;
192192+193193+ // determine the git dir for the local repo
194194+ let git_dir = std::env::var("GIT_DIR").unwrap_or_else(|_| ".git".to_string());
195195+ let repo_path = PathBuf::from(&git_dir);
196196+197197+ // find which bundles we need
198198+ let new_bundles = find_new_bundles(&repo_path, &state.bundles).await?;
199199+200200+ if !new_bundles.is_empty() {
201201+ eprintln!(
202202+ "git-remote-pds: fetching {} bundle(s) from {}",
203203+ new_bundles.len(),
204204+ handle
205205+ );
206206+ download_and_apply(&client, &did, &repo_path, &new_bundles).await?;
207207+ }
208208+209209+ // blank line signals fetch complete (no ref updates from our side)
210210+ println!();
211211+212212+ Ok(())
213213+}
214214+215215+/// Handles a batch of `push` commands.
216216+///
217217+/// Authenticates, pushes all local refs, reports results.
218218+async fn handle_push(handle: &str, repo_name: &str, push_lines: &[String]) -> Result<(), String> {
219219+ let (pds_url, _did) = resolve_pds_url(handle).await?;
220220+221221+ // resolve authentication
222222+ let auth = auth::resolve_auth(handle, &pds_url).await?;
223223+224224+ let client = PdsClient::with_auth(&pds_url, &auth.access_jwt);
225225+226226+ // determine repo path from cwd
227227+ let repo_path =
228228+ std::env::current_dir().map_err(|e| format!("failed to get current directory: {}", e))?;
229229+230230+ eprintln!("git-remote-pds: pushing to {}/{}", handle, repo_name);
231231+232232+ // push using the library
233233+ push::push(&client, &auth.did, repo_name, &repo_path).await?;
234234+235235+ // report ok for each push refspec
236236+ for line in push_lines {
237237+ // format: "push <src>:<dst>" or "push +<src>:<dst>"
238238+ if let Some(refspec) = line.strip_prefix("push ") {
239239+ let refspec = refspec.trim_start_matches('+');
240240+ let dst = refspec.split_once(':').map(|(_, d)| d).unwrap_or(refspec);
241241+ println!("ok {}", dst);
242242+ }
243243+ }
244244+245245+ // blank line terminates push response
246246+ println!();
247247+248248+ Ok(())
249249+}
250250+251251+#[cfg(test)]
252252+mod tests {
253253+ use super::*;
254254+255255+ #[test]
256256+ fn parse_pds_url_valid() {
257257+ let (handle, repo) = parse_pds_url("pds://alice.test/my-repo").unwrap();
258258+ assert_eq!(handle, "alice.test");
259259+ assert_eq!(repo, "my-repo");
260260+ }
261261+262262+ #[test]
263263+ fn parse_pds_url_missing_scheme() {
264264+ assert!(parse_pds_url("alice.test/my-repo").is_err());
265265+ }
266266+267267+ #[test]
268268+ fn parse_pds_url_missing_repo() {
269269+ assert!(parse_pds_url("pds://alice.test").is_err());
270270+ }
271271+272272+ #[test]
273273+ fn parse_pds_url_empty_parts() {
274274+ assert!(parse_pds_url("pds:///my-repo").is_err());
275275+ assert!(parse_pds_url("pds://alice.test/").is_err());
276276+ }
277277+}
+594
tests/e2e_tests.rs
···11+//! End-to-end tests against a real local PDS.
22+//!
33+//! Gated behind the `e2e` feature flag. Requires a running PDS at
44+//! localhost:3000 with a test account created via scripts/pds-dev/.
55+//!
66+//! Run with: cargo test -p pds-git-remote --features e2e -- --test-threads=1
77+#![cfg(feature = "e2e")]
88+99+use std::path::Path;
1010+1111+use pds_git_remote::fetch::{FetchResult, clone_repo, fetch_repo};
1212+use pds_git_remote::pds_client::PdsClient;
1313+use pds_git_remote::push::{PushResult, push};
1414+use pds_git_remote::types::COLLECTION;
1515+use tokio::fs;
1616+1717+const PDS_URL: &str = "http://localhost:3000";
1818+const TEST_HANDLE: &str = "alice.test";
1919+const TEST_PASSWORD: &str = "test-password-123";
2020+2121+/// Checks whether the local PDS is reachable.
2222+async fn pds_is_available() -> bool {
2323+ let url = format!("{}/xrpc/_health", PDS_URL);
2424+ reqwest::get(&url)
2525+ .await
2626+ .is_ok_and(|r| r.status().is_success())
2727+}
2828+2929+/// Logs in and returns an authenticated client plus the user's DID.
3030+async fn login_client() -> (PdsClient, String) {
3131+ let mut client = PdsClient::new(PDS_URL);
3232+ let session = client
3333+ .login(TEST_HANDLE, TEST_PASSWORD)
3434+ .await
3535+ .expect("login failed");
3636+ (client, session.did)
3737+}
3838+3939+/// Skips the test if the PDS is not running.
4040+macro_rules! require_pds {
4141+ () => {
4242+ if !pds_is_available().await {
4343+ eprintln!("SKIP: PDS not available at {}", PDS_URL);
4444+ return;
4545+ }
4646+ };
4747+}
4848+4949+/// Generates a unique rkey from a prefix using the current timestamp.
5050+///
5151+/// Ensures repeated test runs don't collide with leftover records on PDS.
5252+fn unique_rkey(prefix: &str) -> String {
5353+ use std::time::{SystemTime, UNIX_EPOCH};
5454+ let nanos = SystemTime::now()
5555+ .duration_since(UNIX_EPOCH)
5656+ .unwrap()
5757+ .as_nanos();
5858+ format!("{}-{}", prefix, nanos)
5959+}
6060+6161+// -- helpers for git repo setup --
6262+6363+/// Writes a file inside a directory.
6464+async fn write_file(dir: &Path, name: &str, content: &str) {
6565+ let path = dir.join(name);
6666+ if let Some(parent) = path.parent() {
6767+ fs::create_dir_all(parent).await.unwrap();
6868+ }
6969+ fs::write(&path, content).await.unwrap();
7070+}
7171+7272+/// Configures git author so commits work in CI.
7373+async fn configure_git(dir: &Path) {
7474+ tokio::process::Command::new("git")
7575+ .args(["config", "user.email", "test@test.com"])
7676+ .current_dir(dir)
7777+ .output()
7878+ .await
7979+ .unwrap();
8080+ tokio::process::Command::new("git")
8181+ .args(["config", "user.name", "Test"])
8282+ .current_dir(dir)
8383+ .output()
8484+ .await
8585+ .unwrap();
8686+}
8787+8888+/// Initializes a git repo in a temp dir.
8989+async fn init_repo() -> tempfile::TempDir {
9090+ let tmp = tempfile::tempdir().unwrap();
9191+ tokio::process::Command::new("git")
9292+ .args(["init"])
9393+ .current_dir(tmp.path())
9494+ .output()
9595+ .await
9696+ .unwrap();
9797+ configure_git(tmp.path()).await;
9898+ tmp
9999+}
100100+101101+/// Stages all files and commits.
102102+async fn commit(dir: &Path, message: &str) {
103103+ tokio::process::Command::new("git")
104104+ .args(["add", "-A"])
105105+ .current_dir(dir)
106106+ .output()
107107+ .await
108108+ .unwrap();
109109+ let output = tokio::process::Command::new("git")
110110+ .args(["commit", "-m", message])
111111+ .current_dir(dir)
112112+ .output()
113113+ .await
114114+ .unwrap();
115115+ assert!(
116116+ output.status.success(),
117117+ "commit failed: {}",
118118+ String::from_utf8_lossy(&output.stderr)
119119+ );
120120+}
121121+122122+/// Returns the HEAD commit SHA.
123123+async fn head_sha(dir: &Path) -> String {
124124+ let output = tokio::process::Command::new("git")
125125+ .args(["rev-parse", "HEAD"])
126126+ .current_dir(dir)
127127+ .output()
128128+ .await
129129+ .unwrap();
130130+ String::from_utf8_lossy(&output.stdout).trim().to_string()
131131+}
132132+133133+/// Reads a file's contents as a string.
134134+async fn read_file(dir: &Path, name: &str) -> String {
135135+ fs::read_to_string(dir.join(name)).await.unwrap()
136136+}
137137+138138+// -- E2E tests --
139139+140140+/// Login returns a valid DID, tokens, and handle.
141141+#[tokio::test]
142142+async fn e2e_login() {
143143+ require_pds!();
144144+145145+ let mut client = PdsClient::new(PDS_URL);
146146+ let session = client.login(TEST_HANDLE, TEST_PASSWORD).await.unwrap();
147147+148148+ // did must start with "did:plc:"
149149+ assert!(
150150+ session.did.starts_with("did:plc:"),
151151+ "unexpected DID format: {}",
152152+ session.did
153153+ );
154154+155155+ // tokens must be non-empty JWTs
156156+ assert!(!session.access_jwt.is_empty(), "access_jwt is empty");
157157+ assert!(!session.refresh_jwt.is_empty(), "refresh_jwt is empty");
158158+159159+ // handle must match what we logged in with
160160+ assert_eq!(session.handle, TEST_HANDLE);
161161+}
162162+163163+/// First push creates a state record with one bundle and no prerequisites.
164164+#[tokio::test]
165165+async fn e2e_first_push() {
166166+ require_pds!();
167167+168168+ let (client, did) = login_client().await;
169169+170170+ // create a repo with one commit
171171+ let repo = init_repo().await;
172172+ write_file(repo.path(), "README.md", "# Test repo").await;
173173+ commit(repo.path(), "initial commit").await;
174174+175175+ // push to PDS with a unique rkey
176176+ let rkey = unique_rkey("e2e-first-push");
177177+ let result = push(&client, &did, &rkey, repo.path())
178178+ .await
179179+ .expect("push failed");
180180+181181+ // should report a successful push
182182+ match result {
183183+ PushResult::Pushed {
184184+ bundles_uploaded,
185185+ bytes_uploaded,
186186+ } => {
187187+ assert_eq!(bundles_uploaded, 1, "expected 1 bundle uploaded");
188188+ assert!(bytes_uploaded > 0, "expected non-zero bytes uploaded");
189189+ }
190190+ PushResult::AlreadyUpToDate => {
191191+ panic!("expected Pushed, got AlreadyUpToDate");
192192+ }
193193+ }
194194+195195+ // verify the state record on PDS
196196+ let record = client
197197+ .get_record(&did, COLLECTION, &rkey)
198198+ .await
199199+ .expect("get_record failed")
200200+ .expect("state record should exist");
201201+202202+ let state: serde_json::Value = record.value;
203203+204204+ // should have 1 bundle with no prerequisites
205205+ let bundles = state["bundles"].as_array().unwrap();
206206+ assert_eq!(bundles.len(), 1, "expected 1 bundle in state");
207207+ assert!(
208208+ bundles[0]["prerequisites"].as_array().unwrap().is_empty(),
209209+ "first bundle should have no prerequisites"
210210+ );
211211+ assert!(
212212+ !bundles[0]["tips"].as_array().unwrap().is_empty(),
213213+ "first bundle should have tips"
214214+ );
215215+216216+ // should have refs
217217+ let refs = state["refs"].as_array().unwrap();
218218+ assert!(!refs.is_empty(), "state should have refs");
219219+}
220220+221221+/// Two pushes: state grows to 2 bundles, second has prerequisites.
222222+#[tokio::test]
223223+async fn e2e_incremental_push() {
224224+ require_pds!();
225225+226226+ let (client, did) = login_client().await;
227227+228228+ // create repo and first push
229229+ let repo = init_repo().await;
230230+ write_file(repo.path(), "file1.txt", "first file").await;
231231+ commit(repo.path(), "first commit").await;
232232+233233+ let rkey = unique_rkey("e2e-incremental");
234234+ let result = push(&client, &did, &rkey, repo.path())
235235+ .await
236236+ .expect("first push failed");
237237+ assert!(
238238+ matches!(result, PushResult::Pushed { .. }),
239239+ "first push should succeed"
240240+ );
241241+242242+ // add more commits and push again
243243+ write_file(repo.path(), "file2.txt", "second file").await;
244244+ commit(repo.path(), "second commit").await;
245245+246246+ let result = push(&client, &did, &rkey, repo.path())
247247+ .await
248248+ .expect("second push failed");
249249+ assert!(
250250+ matches!(result, PushResult::Pushed { .. }),
251251+ "second push should succeed"
252252+ );
253253+254254+ // verify state record has 2 bundles
255255+ let record = client
256256+ .get_record(&did, COLLECTION, &rkey)
257257+ .await
258258+ .expect("get_record failed")
259259+ .expect("state record should exist");
260260+261261+ let state: serde_json::Value = record.value;
262262+ let bundles = state["bundles"].as_array().unwrap();
263263+ assert_eq!(bundles.len(), 2, "expected 2 bundles in state");
264264+265265+ // second bundle should have prerequisites (the tips of the first)
266266+ let second_prereqs = bundles[1]["prerequisites"].as_array().unwrap();
267267+ assert!(
268268+ !second_prereqs.is_empty(),
269269+ "second bundle should have prerequisites"
270270+ );
271271+272272+ // the tips of bundle 1 should be in the prerequisites of bundle 2
273273+ let first_tips = bundles[0]["tips"].as_array().unwrap();
274274+ for tip in first_tips {
275275+ assert!(
276276+ second_prereqs.contains(tip),
277277+ "second bundle prerequisites should include first bundle's tips"
278278+ );
279279+ }
280280+}
281281+282282+/// Push twice with no new commits returns AlreadyUpToDate.
283283+#[tokio::test]
284284+async fn e2e_already_up_to_date() {
285285+ require_pds!();
286286+287287+ let (client, did) = login_client().await;
288288+289289+ // create repo and push
290290+ let repo = init_repo().await;
291291+ write_file(repo.path(), "file.txt", "content").await;
292292+ commit(repo.path(), "initial").await;
293293+294294+ let rkey = unique_rkey("e2e-up-to-date");
295295+ push(&client, &did, &rkey, repo.path())
296296+ .await
297297+ .expect("first push failed");
298298+299299+ // push again with no new commits
300300+ let result = push(&client, &did, &rkey, repo.path())
301301+ .await
302302+ .expect("second push failed");
303303+304304+ assert!(
305305+ matches!(result, PushResult::AlreadyUpToDate),
306306+ "expected AlreadyUpToDate on second push with no new commits"
307307+ );
308308+}
309309+310310+/// Upload a blob, reference it in a record, then download — bytes must match.
311311+///
312312+/// Blobs on PDS are only persisted once referenced by a record, so we
313313+/// create a minimal record containing the blob ref before downloading.
314314+#[tokio::test]
315315+async fn e2e_blob_round_trip() {
316316+ require_pds!();
317317+318318+ let (client, did) = login_client().await;
319319+320320+ // upload some arbitrary bytes
321321+ let data = b"hello from pds-git-remote e2e test!".to_vec();
322322+ let blob_ref = client
323323+ .upload_blob(data.clone())
324324+ .await
325325+ .expect("upload_blob failed");
326326+327327+ // the returned blob ref should have a CID
328328+ assert!(!blob_ref.cid().is_empty(), "blob CID should be non-empty");
329329+ assert_eq!(blob_ref.size, data.len() as u64);
330330+331331+ // create a record referencing the blob so PDS persists it
332332+ let record = serde_json::json!({
333333+ "blob": blob_ref,
334334+ "createdAt": "2026-02-13T00:00:00Z",
335335+ });
336336+ let rkey = unique_rkey("e2e-blob-test");
337337+ client
338338+ .put_record(&did, COLLECTION, &rkey, record, None)
339339+ .await
340340+ .expect("put_record failed");
341341+342342+ // download the blob back
343343+ let downloaded = client
344344+ .get_blob(&did, blob_ref.cid())
345345+ .await
346346+ .expect("get_blob failed");
347347+348348+ assert_eq!(downloaded, data, "downloaded bytes should match uploaded");
349349+}
350350+351351+/// Clone from PDS into a new repo — files and history must match the source.
352352+#[tokio::test]
353353+async fn e2e_clone() {
354354+ require_pds!();
355355+356356+ let (client, did) = login_client().await;
357357+358358+ // create a source repo with two commits
359359+ let source = init_repo().await;
360360+ write_file(source.path(), "README.md", "# My Project").await;
361361+ write_file(source.path(), "src/main.rs", "fn main() {}").await;
362362+ commit(source.path(), "initial commit").await;
363363+ write_file(source.path(), "src/lib.rs", "pub fn hello() {}").await;
364364+ commit(source.path(), "add lib").await;
365365+366366+ let source_sha = head_sha(source.path()).await;
367367+368368+ // push to PDS
369369+ let rkey = unique_rkey("e2e-clone");
370370+ push(&client, &did, &rkey, source.path())
371371+ .await
372372+ .expect("push failed");
373373+374374+ // clone into a new empty repo
375375+ let dest = init_repo().await;
376376+ let result = clone_repo(&client, &did, &rkey, dest.path())
377377+ .await
378378+ .expect("clone failed");
379379+380380+ // should have applied 1 bundle
381381+ match result {
382382+ FetchResult::Applied {
383383+ bundles_applied,
384384+ bytes_downloaded,
385385+ } => {
386386+ assert_eq!(bundles_applied, 1, "expected 1 bundle applied");
387387+ assert!(bytes_downloaded > 0, "expected non-zero bytes");
388388+ }
389389+ FetchResult::AlreadyUpToDate => {
390390+ panic!("expected Applied, got AlreadyUpToDate");
391391+ }
392392+ }
393393+394394+ // verify HEAD matches source
395395+ let dest_sha = head_sha(dest.path()).await;
396396+ assert_eq!(dest_sha, source_sha, "cloned HEAD should match source");
397397+398398+ // verify file contents match
399399+ let readme = read_file(dest.path(), "README.md").await;
400400+ assert_eq!(readme, "# My Project");
401401+ let main_rs = read_file(dest.path(), "src/main.rs").await;
402402+ assert_eq!(main_rs, "fn main() {}");
403403+ let lib_rs = read_file(dest.path(), "src/lib.rs").await;
404404+ assert_eq!(lib_rs, "pub fn hello() {}");
405405+}
406406+407407+/// Fetch after clone: push new commits, fetch into clone, verify update.
408408+#[tokio::test]
409409+async fn e2e_fetch() {
410410+ require_pds!();
411411+412412+ let (client, did) = login_client().await;
413413+414414+ // create source repo and push
415415+ let source = init_repo().await;
416416+ write_file(source.path(), "file1.txt", "first").await;
417417+ commit(source.path(), "first").await;
418418+419419+ let rkey = unique_rkey("e2e-fetch");
420420+ push(&client, &did, &rkey, source.path())
421421+ .await
422422+ .expect("first push failed");
423423+424424+ // clone into dest
425425+ let dest = init_repo().await;
426426+ clone_repo(&client, &did, &rkey, dest.path())
427427+ .await
428428+ .expect("clone failed");
429429+430430+ // add more commits to source and push again
431431+ write_file(source.path(), "file2.txt", "second").await;
432432+ commit(source.path(), "second").await;
433433+ let source_sha = head_sha(source.path()).await;
434434+435435+ push(&client, &did, &rkey, source.path())
436436+ .await
437437+ .expect("second push failed");
438438+439439+ // fetch into dest
440440+ let result = fetch_repo(&client, &did, &rkey, dest.path())
441441+ .await
442442+ .expect("fetch failed");
443443+444444+ // should have applied 1 new bundle (the incremental one)
445445+ match result {
446446+ FetchResult::Applied {
447447+ bundles_applied,
448448+ bytes_downloaded,
449449+ } => {
450450+ assert_eq!(bundles_applied, 1, "expected 1 new bundle fetched");
451451+ assert!(bytes_downloaded > 0, "expected non-zero bytes");
452452+ }
453453+ FetchResult::AlreadyUpToDate => {
454454+ panic!("expected Applied, got AlreadyUpToDate");
455455+ }
456456+ }
457457+458458+ // verify HEAD matches source after fetch
459459+ let dest_sha = head_sha(dest.path()).await;
460460+ assert_eq!(dest_sha, source_sha, "fetched HEAD should match source");
461461+462462+ // verify the new file is present
463463+ let file2 = read_file(dest.path(), "file2.txt").await;
464464+ assert_eq!(file2, "second");
465465+}
466466+467467+/// Push via git remote helper binary, then clone — files and HEAD must match.
468468+#[tokio::test]
469469+async fn e2e_remote_helper_push_and_clone() {
470470+ require_pds!();
471471+472472+ let binary = env!("CARGO_BIN_EXE_git-remote-pds");
473473+474474+ // put the binary's directory on PATH so git can find it
475475+ let bin_dir = std::path::Path::new(binary)
476476+ .parent()
477477+ .unwrap()
478478+ .to_str()
479479+ .unwrap();
480480+ let path_env = format!("{}:{}", bin_dir, std::env::var("PATH").unwrap_or_default());
481481+482482+ let rkey = unique_rkey("e2e-remote-helper");
483483+484484+ // create source repo with files
485485+ let source = init_repo().await;
486486+ write_file(source.path(), "README.md", "# Remote Helper Test").await;
487487+ write_file(source.path(), "src/app.rs", "fn app() {}").await;
488488+ commit(source.path(), "initial commit").await;
489489+490490+ let source_sha = head_sha(source.path()).await;
491491+492492+ // add the pds remote
493493+ let remote_url = format!("pds://{}/{}", TEST_HANDLE, rkey);
494494+ let output = tokio::process::Command::new("git")
495495+ .args(["remote", "add", "pds", &remote_url])
496496+ .current_dir(source.path())
497497+ .output()
498498+ .await
499499+ .unwrap();
500500+ assert!(
501501+ output.status.success(),
502502+ "git remote add failed: {}",
503503+ String::from_utf8_lossy(&output.stderr)
504504+ );
505505+506506+ // get the default branch name
507507+ let output = tokio::process::Command::new("git")
508508+ .args(["branch", "--show-current"])
509509+ .current_dir(source.path())
510510+ .output()
511511+ .await
512512+ .unwrap();
513513+ let branch = String::from_utf8_lossy(&output.stdout).trim().to_string();
514514+515515+ // push via remote helper
516516+ let output = tokio::process::Command::new("git")
517517+ .args(["push", "pds", &branch])
518518+ .current_dir(source.path())
519519+ .env("PATH", &path_env)
520520+ .env("PDS_URL", PDS_URL)
521521+ .env("PDS_HANDLE", TEST_HANDLE)
522522+ .env("PDS_PASSWORD", TEST_PASSWORD)
523523+ .output()
524524+ .await
525525+ .unwrap();
526526+ assert!(
527527+ output.status.success(),
528528+ "git push failed: stdout={} stderr={}",
529529+ String::from_utf8_lossy(&output.stdout),
530530+ String::from_utf8_lossy(&output.stderr)
531531+ );
532532+533533+ // clone via remote helper into a new directory
534534+ let dest = tempfile::tempdir().unwrap();
535535+ let output = tokio::process::Command::new("git")
536536+ .args(["clone", &remote_url, dest.path().to_str().unwrap()])
537537+ .env("PATH", &path_env)
538538+ .env("PDS_URL", PDS_URL)
539539+ .env("PDS_HANDLE", TEST_HANDLE)
540540+ .env("PDS_PASSWORD", TEST_PASSWORD)
541541+ .output()
542542+ .await
543543+ .unwrap();
544544+ assert!(
545545+ output.status.success(),
546546+ "git clone failed: stdout={} stderr={}",
547547+ String::from_utf8_lossy(&output.stdout),
548548+ String::from_utf8_lossy(&output.stderr)
549549+ );
550550+551551+ // verify HEAD SHAs match
552552+ let dest_sha = head_sha(dest.path()).await;
553553+ assert_eq!(dest_sha, source_sha, "cloned HEAD should match source HEAD");
554554+555555+ // verify file contents match
556556+ let readme = read_file(dest.path(), "README.md").await;
557557+ assert_eq!(readme, "# Remote Helper Test");
558558+ let app_rs = read_file(dest.path(), "src/app.rs").await;
559559+ assert_eq!(app_rs, "fn app() {}");
560560+}
561561+562562+/// Fetch with no new commits returns AlreadyUpToDate.
563563+#[tokio::test]
564564+async fn e2e_fetch_already_up_to_date() {
565565+ require_pds!();
566566+567567+ let (client, did) = login_client().await;
568568+569569+ // create source repo and push
570570+ let source = init_repo().await;
571571+ write_file(source.path(), "file.txt", "content").await;
572572+ commit(source.path(), "initial").await;
573573+574574+ let rkey = unique_rkey("e2e-fetch-utd");
575575+ push(&client, &did, &rkey, source.path())
576576+ .await
577577+ .expect("push failed");
578578+579579+ // clone into dest
580580+ let dest = init_repo().await;
581581+ clone_repo(&client, &did, &rkey, dest.path())
582582+ .await
583583+ .expect("clone failed");
584584+585585+ // fetch again with no new commits
586586+ let result = fetch_repo(&client, &did, &rkey, dest.path())
587587+ .await
588588+ .expect("fetch failed");
589589+590590+ assert!(
591591+ matches!(result, FetchResult::AlreadyUpToDate),
592592+ "expected AlreadyUpToDate when no new commits"
593593+ );
594594+}
+114
tests/push_tests.rs
···11+//! Integration tests for push flow logic.
22+//!
33+//! Tests the bundle-creation and state-assembly parts of push.
44+//! Full end-to-end push tests require a running PDS (see scripts/pds-dev/).
55+66+use std::path::Path;
77+use tokio::fs;
88+99+use pds_git_remote::bundle::create_full_bundle;
1010+use pds_git_remote::pds_client::PdsClient;
1111+use pds_git_remote::push::push;
1212+1313+/// Helper: write a file inside a directory.
1414+async fn write_file(dir: &Path, name: &str, content: &str) {
1515+ let path = dir.join(name);
1616+ if let Some(parent) = path.parent() {
1717+ fs::create_dir_all(parent).await.unwrap();
1818+ }
1919+ fs::write(&path, content).await.unwrap();
2020+}
2121+2222+/// Helper: configure git author so commits work in CI.
2323+async fn configure_git(dir: &Path) {
2424+ tokio::process::Command::new("git")
2525+ .args(["config", "user.email", "test@test.com"])
2626+ .current_dir(dir)
2727+ .output()
2828+ .await
2929+ .unwrap();
3030+ tokio::process::Command::new("git")
3131+ .args(["config", "user.name", "Test"])
3232+ .current_dir(dir)
3333+ .output()
3434+ .await
3535+ .unwrap();
3636+}
3737+3838+/// Helper: init a git repo in a temp dir.
3939+async fn init_repo() -> tempfile::TempDir {
4040+ let tmp = tempfile::tempdir().unwrap();
4141+ tokio::process::Command::new("git")
4242+ .args(["init"])
4343+ .current_dir(tmp.path())
4444+ .output()
4545+ .await
4646+ .unwrap();
4747+ configure_git(tmp.path()).await;
4848+ tmp
4949+}
5050+5151+/// Helper: stage all and commit.
5252+async fn commit(dir: &Path, message: &str) {
5353+ tokio::process::Command::new("git")
5454+ .args(["add", "-A"])
5555+ .current_dir(dir)
5656+ .output()
5757+ .await
5858+ .unwrap();
5959+ let output = tokio::process::Command::new("git")
6060+ .args(["commit", "-m", message])
6161+ .current_dir(dir)
6262+ .output()
6363+ .await
6464+ .unwrap();
6565+ assert!(
6666+ output.status.success(),
6767+ "commit failed: {}",
6868+ String::from_utf8_lossy(&output.stderr)
6969+ );
7070+}
7171+7272+/// Push to a nonexistent PDS should fail with a connection error, not a panic.
7373+///
7474+/// This verifies the push function handles network errors gracefully.
7575+#[tokio::test]
7676+async fn push_to_unreachable_pds_returns_error() {
7777+ let repo = init_repo().await;
7878+ write_file(repo.path(), "f.txt", "hello").await;
7979+ commit(repo.path(), "initial").await;
8080+8181+ // point at a PDS that doesn't exist
8282+ let client = PdsClient::with_auth("http://127.0.0.1:19999", "fake-token");
8383+8484+ let result = push(&client, "did:plc:test", "test-repo", repo.path()).await;
8585+ assert!(result.is_err());
8686+}
8787+8888+/// Push on an empty repo (no commits) should return an error.
8989+#[tokio::test]
9090+async fn push_empty_repo_returns_error() {
9191+ let repo = init_repo().await;
9292+9393+ let client = PdsClient::with_auth("http://127.0.0.1:19999", "fake-token");
9494+ let result = push(&client, "did:plc:test", "test-repo", repo.path()).await;
9595+ assert!(result.is_err());
9696+ assert!(result.unwrap_err().contains("no local branches"));
9797+}
9898+9999+/// Verify that create_full_bundle produces data suitable for a first push.
100100+#[tokio::test]
101101+async fn first_push_creates_full_bundle() {
102102+ let repo = init_repo().await;
103103+ write_file(repo.path(), "index.md", "# Site").await;
104104+ commit(repo.path(), "init").await;
105105+106106+ let bundle = create_full_bundle(repo.path()).await.unwrap();
107107+108108+ // full bundle has no prerequisites
109109+ assert!(bundle.prerequisites.is_empty());
110110+ // has at least one tip
111111+ assert!(!bundle.tips.is_empty());
112112+ // bundle data is non-trivial
113113+ assert!(bundle.data.len() > 10);
114114+}
+8
todo.txt
···11+22+33+- more thorough tests
44+- inspect pds via pds browser
55+- test git commands directly via cli
66+- add an e2e test that tests with larger files
77+- add an e2e test that tests what happens with a conflict
88+- separate into own repo