···11-[workspace]
22-resolver = "2"
33-members = [
44- "crates/tandem-core",
55- "crates/tandem-server",
66- "crates/tandem-cli",
77-]
88-99-[workspace.package]
1010-version = "0.1.0"
1111-edition = "2024"
1212-authors = ["Tandem Contributors"]
1313-license = "MIT OR Apache-2.0"
1414-repository = "https://github.com/laulauland/tandem"
1515-1616-[workspace.dependencies]
1717-# Shared core library
1818-tandem-core = { path = "crates/tandem-core" }
1919-2020-# Serialization
2121-serde = { version = "1.0", features = ["derive"] }
2222-serde_json = "1.0"
2323-2424-# Utilities
2525-uuid = { version = "1.11", features = ["v4", "serde"] }
2626-chrono = { version = "0.4", features = ["serde"] }
2727-2828-# CRDT
2929-yrs = "0.21"
3030-3131-# Async runtime
3232-tokio = { version = "1.0", features = ["full"] }
3333-async-trait = "0.1"
3434-futures-util = "0.3"
3535-3636-# Web framework
3737-axum = { version = "0.7", features = ["ws"] }
3838-tower = "0.5"
3939-tower-http = { version = "0.6", features = ["cors", "trace"] }
4040-4141-# Logging
4242-tracing = "0.1"
4343-tracing-subscriber = { version = "0.3", features = ["env-filter"] }
4444-4545-# Database
4646-sqlx = { version = "0.8", features = ["runtime-tokio", "sqlite", "chrono"] }
4747-4848-# CLI
4949-clap = { version = "4.5", features = ["derive"] }
5050-5151-# Encoding
5252-hex = "0.4"
5353-rand = "0.8"
5454-base64 = "0.22"
-420
QA.md
···11-# Tandem QA Test Plan
22-33-## Prerequisites
44-55-1. Rust toolchain installed
66-2. `jj` (Jujutsu) installed
77-3. `sqlite3` CLI available
88-4. `curl` and `jq` for API testing
99-5. Two terminal windows minimum
1010-1111-## Build
1212-1313-```bash
1414-cd /home/lau/code/laulauland/tandem
1515-cargo build --release
1616-```
1717-1818-**Expected**: Build completes with warnings only, no errors. Binaries at:
1919-- `target/release/tandem-server`
2020-- `target/release/jjf`
2121-2222----
2323-2424-## Test 1: Server Startup
2525-2626-### Steps
2727-```bash
2828-# Terminal 1
2929-DATABASE_URL=sqlite:tandem.db DATA_DIR=./data ./target/release/tandem-server
3030-```
3131-3232-### Expected Output
3333-```
3434-Server running on http://localhost:3000
3535-```
3636-3737-### Verify
3838-```bash
3939-curl http://localhost:3000/health
4040-```
4141-4242-### Expected Response
4343-```json
4444-{"status":"ok"}
4545-```
4646-4747----
4848-4949-## Test 2: User Creation and Authentication
5050-5151-### Steps
5252-```bash
5353-# Create database tables (auto-created on first run, but verify)
5454-sqlite3 tandem.db ".tables"
5555-```
5656-5757-### Expected Output
5858-```
5959-auth_tokens repo_access repos users
6060-```
6161-6262-### Create Test User
6363-```bash
6464-sqlite3 tandem.db "INSERT INTO users (id, email, name, password_hash)
6565- VALUES ('user-alice', 'alice@example.com', 'Alice', 'password123');"
6666-```
6767-6868-### Login
6969-```bash
7070-curl -s -X POST http://localhost:3000/api/auth/login \
7171- -H "Content-Type: application/json" \
7272- -d '{"email":"alice@example.com","password":"password123"}'
7373-```
7474-7575-### Expected Response
7676-```json
7777-{
7878- "token": "<64-char-hex-string>",
7979- "expires_at": "<RFC3339-timestamp>"
8080-}
8181-```
8282-8383-### Save Token
8484-```bash
8585-export TOKEN="<paste-token-here>"
8686-```
8787-8888-### Verify Token
8989-```bash
9090-curl -s http://localhost:3000/api/auth/me \
9191- -H "Authorization: Bearer $TOKEN"
9292-```
9393-9494-### Expected Response
9595-```json
9696-{
9797- "id": "user-alice",
9898- "email": "alice@example.com",
9999- "name": "Alice"
100100-}
101101-```
102102-103103----
104104-105105-## Test 3: Repository Creation
106106-107107-### Steps
108108-```bash
109109-curl -s -X POST http://localhost:3000/api/repos \
110110- -H "Authorization: Bearer $TOKEN" \
111111- -H "Content-Type: application/json" \
112112- -d '{"name":"my-project","org":"acme"}'
113113-```
114114-115115-### Expected Response
116116-```json
117117-{
118118- "id": "<uuid>",
119119- "name": "my-project",
120120- "org": "acme",
121121- "created_at": "<RFC3339-timestamp>"
122122-}
123123-```
124124-125125-### Save Repo ID
126126-```bash
127127-export REPO_ID="<paste-repo-id-here>"
128128-```
129129-130130-### Verify Repo Created
131131-```bash
132132-curl -s http://localhost:3000/api/repos \
133133- -H "Authorization: Bearer $TOKEN"
134134-```
135135-136136-### Expected Response
137137-Array containing the created repo (user has admin access as creator).
138138-139139----
140140-141141-## Test 4: Access Control
142142-143143-### Create Second User Without Access
144144-```bash
145145-sqlite3 tandem.db "INSERT INTO users (id, email, name, password_hash)
146146- VALUES ('user-bob', 'bob@example.com', 'Bob', 'password456');"
147147-148148-# Login as Bob
149149-BOB_TOKEN=$(curl -s -X POST http://localhost:3000/api/auth/login \
150150- -H "Content-Type: application/json" \
151151- -d '{"email":"bob@example.com","password":"password456"}' | jq -r .token)
152152-```
153153-154154-### Try to Access Repo as Bob
155155-```bash
156156-curl -s http://localhost:3000/api/repos/$REPO_ID \
157157- -H "Authorization: Bearer $BOB_TOKEN"
158158-```
159159-160160-### Expected Response
161161-HTTP 403 Forbidden (Bob has no access to Alice's repo)
162162-163163-### Grant Bob Read Access
164164-```bash
165165-sqlite3 tandem.db "INSERT INTO repo_access (repo_id, user_id, role)
166166- VALUES ('$REPO_ID', 'user-bob', 'read');"
167167-```
168168-169169-### Retry Access
170170-```bash
171171-curl -s http://localhost:3000/api/repos/$REPO_ID \
172172- -H "Authorization: Bearer $BOB_TOKEN"
173173-```
174174-175175-### Expected Response
176176-```json
177177-{
178178- "id": "<repo-id>",
179179- "name": "my-project",
180180- "org": "acme",
181181- "created_at": "<timestamp>"
182182-}
183183-```
184184-185185----
186186-187187-## Test 5: jjf CLI - Link Repository
188188-189189-### Setup Local jj Repo
190190-```bash
191191-# Terminal 2
192192-mkdir /tmp/test-project && cd /tmp/test-project
193193-jj init
194194-echo "Hello World" > README.md
195195-jj new -m "Initial commit"
196196-```
197197-198198-### Link to Forge
199199-```bash
200200-/home/lau/code/laulauland/tandem/target/release/jjf link \
201201- http://localhost:3000/acme/my-project \
202202- --token $TOKEN
203203-```
204204-205205-### Expected Output
206206-```
207207-✓ Linked to forge: http://localhost:3000/acme/my-project
208208- Run 'jjf daemon start' to begin syncing
209209-```
210210-211211-### Verify Config Created
212212-```bash
213213-cat .jj/forge.toml
214214-```
215215-216216-### Expected Content
217217-```toml
218218-[forge]
219219-url = "http://localhost:3000/acme/my-project"
220220-```
221221-222222----
223223-224224-## Test 6: jjf CLI - Status
225225-226226-### Steps
227227-```bash
228228-/home/lau/code/laulauland/tandem/target/release/jjf status
229229-```
230230-231231-### Expected Output
232232-```
233233-Repository: /tmp/test-project
234234-Forge: http://localhost:3000/acme/my-project
235235-Status: Not syncing (daemon not running)
236236-```
237237-238238----
239239-240240-## Test 7: jjf Daemon - WebSocket Sync
241241-242242-### Start Daemon
243243-```bash
244244-# Terminal 2 (in /tmp/test-project)
245245-/home/lau/code/laulauland/tandem/target/release/jjf daemon start
246246-```
247247-248248-### Expected Output
249249-```
250250-Starting daemon for /tmp/test-project
251251-Connecting to forge: ws://localhost:3000/sync/my-project
252252-```
253253-254254-### Verify in Server Logs (Terminal 1)
255255-```
256256-Client connected to sync for repo my-project
257257-```
258258-259259-### Stop Daemon
260260-Press `Ctrl+C`
261261-262262-### Expected Output
263263-```
264264-Daemon shutting down
265265-```
266266-267267----
268268-269269-## Test 8: jjf Clone
270270-271271-### Steps
272272-```bash
273273-# Terminal 2
274274-cd /tmp
275275-/home/lau/code/laulauland/tandem/target/release/jjf clone \
276276- http://localhost:3000/acme/my-project \
277277- --token $TOKEN
278278-```
279279-280280-### Expected Output
281281-```
282282-Cloning into '/tmp/my-project'...
283283- Syncing initial state...
284284- ✓ Received X changes, Y bookmarks
285285-✓ Linked to forge: http://localhost:3000/acme/my-project
286286- Run 'jjf daemon start' to begin syncing
287287-✓ Cloned repository to /tmp/my-project
288288-```
289289-290290-### Verify Clone
291291-```bash
292292-cd /tmp/my-project
293293-ls -la .jj/
294294-cat .jj/forge.toml
295295-```
296296-297297----
298298-299299-## Test 9: WebSocket Broadcast (Multi-Client Sync)
300300-301301-### Setup
302302-Start two daemon instances connected to the same repo.
303303-304304-### Terminal 2
305305-```bash
306306-cd /tmp/test-project
307307-/home/lau/code/laulauland/tandem/target/release/jjf daemon start
308308-```
309309-310310-### Terminal 3
311311-```bash
312312-cd /tmp/my-project # The cloned repo
313313-/home/lau/code/laulauland/tandem/target/release/jjf daemon start
314314-```
315315-316316-### Expected Server Logs
317317-```
318318-Client connected to sync for repo my-project
319319-Client connected to sync for repo my-project
320320-```
321321-322322-### Test Sync
323323-Make a change in one repo and verify it appears in the other.
324324-325325-**Note**: Full bidirectional sync requires the daemon to push local jj changes, which needs additional integration with jj-lib's operation log watching.
326326-327327----
328328-329329-## Test 10: REST API - Changes and Bookmarks
330330-331331-### List Changes
332332-```bash
333333-curl -s http://localhost:3000/api/repos/$REPO_ID/changes \
334334- -H "Authorization: Bearer $TOKEN"
335335-```
336336-337337-### Expected Response
338338-```json
339339-[]
340340-```
341341-(Empty until changes are synced from a jj repo)
342342-343343-### List Bookmarks
344344-```bash
345345-curl -s http://localhost:3000/api/repos/$REPO_ID/bookmarks \
346346- -H "Authorization: Bearer $TOKEN"
347347-```
348348-349349-### Expected Response
350350-```json
351351-[]
352352-```
353353-354354----
355355-356356-## Test 11: Content Endpoint
357357-358358-### Store Test Content (via server)
359359-This requires content to be synced first. Manual test:
360360-361361-```bash
362362-curl -s http://localhost:3000/api/repos/$REPO_ID/content/abc123 \
363363- -H "Authorization: Bearer $TOKEN"
364364-```
365365-366366-### Expected Response
367367-HTTP 404 (content not found - expected for non-existent hash)
368368-369369----
370370-371371-## Test 12: Events WebSocket
372372-373373-### Connect to Events
374374-```bash
375375-# Requires websocat or similar
376376-websocat ws://localhost:3000/events/$REPO_ID
377377-```
378378-379379-### Expected Initial Message
380380-```json
381381-{"type":"connected","repo_id":"<repo-id>"}
382382-```
383383-384384----
385385-386386-## Test 13: jj-lib Integration
387387-388388-### Verify jj Repository Reading
389389-```bash
390390-cd /tmp/test-project
391391-/home/lau/code/laulauland/tandem/target/release/jjf list
392392-```
393393-394394-### Expected Output
395395-List of changes from the jj repository (may be empty for new repos).
396396-397397----
398398-399399-## Known Limitations
400400-401401-1. **Tree Hash Placeholder**: Tree hashes use first 20 bytes of change_id (jj-lib async limitation)
402402-2. **Password Storage**: Plaintext comparison (use bcrypt in production)
403403-3. **Token in CLI**: Currently passed as flag (should use keychain)
404404-4. **Daemon Status**: `jjf daemon status` not fully implemented
405405-5. **Presence Warnings**: Require daemon IPC (currently stub)
406406-6. **Log Interception**: `jjf alias` log presence injection is stub
407407-408408----
409409-410410-## Cleanup
411411-412412-```bash
413413-# Stop server (Ctrl+C in Terminal 1)
414414-415415-# Remove test data
416416-rm -rf /tmp/test-project /tmp/my-project
417417-rm tandem.db data/
418418-419419-# Or keep for further testing
420420-```
-296
README.md
···11-# Tandem
22-33-A native forge for [Jujutsu](https://github.com/martinvonz/jj) (jj) with real-time multiplayer collaboration. Tandem syncs jj changes, bookmarks, and user presence across multiple clients using CRDTs.
44-55-## Overview
66-77-Tandem provides a server-client architecture where:
88-99-- The **server** maintains the authoritative Y.Doc (Yrs CRDT document) for each repository
1010-- **Clients** run a daemon that syncs local changes to the server over WebSocket
1111-- All data merges automatically without conflicts using Yrs (the Rust port of Yjs)
1212-1313-This enables multiple developers to work on the same repository simultaneously with real-time visibility into who is editing what.
1414-1515-## Architecture
1616-1717-Tandem is organized into three crates:
1818-1919-### tandem-core
2020-2121-The shared library containing:
2222-2323-- **types.rs** - Core data types: `ChangeId`, `TreeHash`, `Change`, `ChangeRecord`, `Bookmark`, `PresenceInfo`
2424-- **sync.rs** - `ForgeDoc` wrapping a Y.Doc with maps for changes, bookmarks, presence, and subdocuments for lazy content loading
2525-2626-### tandem-server
2727-2828-An Axum-based HTTP/WebSocket server:
2929-3030-- **sync.rs** - WebSocket endpoint at `/sync/:repo_id` handling bidirectional CRDT sync
3131-- **docs.rs** - `DocManager` loads and persists Y.Doc state to `.yrs` files
3232-- REST API for repositories, changes, bookmarks, presence, and content
3333-3434-### tandem-cli
3535-3636-The `jjf` command-line tool and background daemon:
3737-3838-- **main.rs** - CLI commands: `init`, `link`, `clone`, `daemon start`
3939-- **daemon.rs** - Background sync daemon connecting to the forge
4040-- **presence.rs** - Tracks who is editing which change
4141-- **offline.rs** - Queues operations when disconnected for later replay
4242-- **content.rs** - Lazy fetching of tree/blob content from the server
4343-4444-## Data Model
4545-4646-### Core Types
4747-4848-**ChangeId** - A stable 32-byte identifier for a change that persists across rebases. This is jj's native change ID.
4949-5050-**Change** - The unit of work containing:
5151-- `id: ChangeId` - Stable identifier
5252-- `tree: TreeHash` - Content-addressed 20-byte hash of the tree
5353-- `parents: Vec<ChangeId>` - Parent changes
5454-- `description: String` - Commit message
5555-- `author: Identity` - Name and email
5656-- `timestamp: DateTime<Utc>`
5757-5858-**ChangeRecord** - The CRDT-friendly wrapper stored in Y.Doc:
5959-- `record_id: Uuid` - Unique key for the Y.Map entry
6060-- `change_id: ChangeId` - The actual change ID
6161-- `visible: bool` - False when abandoned/hidden
6262-- All fields from `Change`
6363-6464-The distinction matters: a single `ChangeId` can have multiple `ChangeRecord` entries when a change diverges across clients before syncing. The CRDT merges them all, and the application layer decides how to present divergence.
6565-6666-**Bookmark** - A named pointer to a change (like a git branch):
6767-- `name: String` - Bookmark name
6868-- `target: ChangeId` - The change it points to
6969-- `protected: bool` - Whether rules apply
7070-- `rules: BookmarkRules` - CI/review requirements
7171-7272-**PresenceInfo** - Real-time editing status:
7373-- `user_id: String` - Username
7474-- `change_id: ChangeId` - Currently edited change
7575-- `device: String` - Device name
7676-- `timestamp: DateTime<Utc>` - Last update time (stale after 5 minutes)
7777-7878-### Y.Doc Structure
7979-8080-Each repository has **one main Y.Doc** for metadata, plus **separate subdocuments** (each its own Y.Doc) for content:
8181-8282-```
8383-ForgeDoc
8484-├── Main Doc (one per repo)
8585-│ ├── Y.Map("changes") → {record_id: JSON(ChangeRecord)}
8686-│ ├── Y.Map("bookmarks") → {name: ChangeId_hex}
8787-│ └── Y.Map("presence") → {user_id: JSON(PresenceInfo)}
8888-│
8989-└── Subdocuments (HashMap<hash, Doc>)
9090- ├── "abc123" → Y.Doc { Y.Map("data") → {content: base64} }
9191- ├── "def456" → Y.Doc { Y.Map("data") → {content: base64} }
9292- └── ...
9393-```
9494-9595-This design enables:
9696-1. **Fast metadata sync** - The main doc is small and syncs quickly
9797-2. **Lazy content loading** - Each content blob is a separate Y.Doc fetched on-demand
9898-3. **Independent sync** - Subdocuments can sync independently, so you only fetch content you need
9999-100100-## Sync Protocol
101101-102102-### State Vector Exchange
103103-104104-Yrs uses state vectors to track what each peer has seen. The sync flow:
105105-106106-1. **Client connects** via WebSocket to `/sync/:repo_id`
107107-2. **Client sends state vector** - A compact encoding of which updates it has
108108-3. **Server computes diff** - Encodes only the updates the client lacks
109109-4. **Server sends update** - Binary Yrs update packet
110110-5. **Client applies update** - Merges into its local Y.Doc
111111-6. **Bidirectional sync continues** - Either side can send updates
112112-113113-```
114114-Client Server
115115- | |
116116- |-- Binary(state_vector) ------>|
117117- | | (compute diff)
118118- |<----- Binary(update) ---------|
119119- | |
120120- |-- Binary(local_update) ------>| (from local edit)
121121- | | (apply, save, broadcast)
122122- | |
123123-```
124124-125125-### Server-Side Sync (tandem-server/src/sync.rs)
126126-127127-The server handles each WebSocket message:
128128-129129-1. **Try to apply as update** - If valid Yrs update, apply it
130130-2. **On success** - Save to disk, broadcast to other clients (excluding sender)
131131-3. **On failure** - Assume it's a state vector, compute and send diff
132132-133133-The `SyncManager` maintains broadcast channels per repository so updates propagate to all connected clients.
134134-135135-### Client-Side Sync (tandem-cli/src/daemon.rs)
136136-137137-The daemon:
138138-139139-1. Connects to the forge WebSocket
140140-2. Sends its state vector to request initial sync
141141-3. Receives and applies updates from the server
142142-4. When local changes happen, sends updates to the server
143143-5. On disconnect, enters offline mode and queues operations
144144-145145-## Conflict Resolution
146146-147147-### Why CRDTs Avoid Traditional Conflicts
148148-149149-Yrs implements a CRDT (Conflict-free Replicated Data Type). Every operation is designed to commute - the same set of operations applied in any order produces the same result.
150150-151151-For Tandem:
152152-153153-- **Changes** are keyed by unique `record_id` UUIDs. Two clients creating a record for the same change get two records, not a conflict.
154154-- **Bookmarks** use last-writer-wins semantics on the Y.Map. Concurrent moves to different targets result in one winning.
155155-- **Presence** uses last-writer-wins per user. Stale entries are filtered out by timestamp.
156156-157157-### Divergence Handling
158158-159159-When the same `ChangeId` gets edited on two disconnected clients:
160160-161161-1. Each client creates a new `ChangeRecord` with a unique `record_id`
162162-2. On reconnect, both records sync to all clients
163163-3. `get_change_records(change_id)` returns multiple records
164164-4. The application must decide which is canonical (by timestamp, by user, or by user choice)
165165-166166-Hidden/abandoned changes are marked with `visible: false` rather than deleted, preserving history.
167167-168168-### Edge Cases
169169-170170-- **Bookmark races** - If Alice moves `main` to change A while Bob moves it to B, one wins. The CRDT resolves this deterministically but the "loser" may need to re-move the bookmark.
171171-- **Offline queuing** - Operations made while disconnected are stored in `.jj/forge-queue.json` and replayed on reconnect.
172172-- **Stale presence** - Presence entries older than 5 minutes are filtered out.
173173-174174-## Getting Started
175175-176176-### Starting the Server
177177-178178-```bash
179179-cd crates/tandem-server
180180-DATABASE_URL=sqlite:tandem.db DATA_DIR=./data cargo run
181181-```
182182-183183-The server starts on `http://localhost:3000` with:
184184-- REST API at `/api/*`
185185-- WebSocket sync at `/sync/:repo_id`
186186-- Health check at `/health`
187187-188188-### Linking a Repository
189189-190190-In an existing jj repository:
191191-192192-```bash
193193-jjf link https://forge.example.com/org/myrepo --token <auth_token>
194194-```
195195-196196-This:
197197-1. Tests the connection to the forge
198198-2. Creates `.jj/forge.toml` with the forge URL
199199-3. Prints instructions to start the daemon
200200-201201-### Cloning from Forge
202202-203203-```bash
204204-jjf clone https://forge.example.com/org/myrepo
205205-```
206206-207207-This:
208208-1. Creates the target directory
209209-2. Runs `jj init`
210210-3. Links to the forge
211211-4. Pulls initial state via WebSocket sync
212212-5. Saves the Y.Doc state to `.jj/forge-doc.bin`
213213-214214-### Running the Daemon
215215-216216-```bash
217217-jjf daemon start
218218-```
219219-220220-The daemon:
221221-1. Loads forge config from `.jj/forge.toml`
222222-2. Connects to the forge WebSocket
223223-3. Syncs bidirectionally in real-time
224224-4. Tracks presence (which change you're editing)
225225-5. Warns when someone else is editing the same change
226226-6. Queues operations when offline, replays on reconnect
227227-228228-### Presence Tracking
229229-230230-The daemon uses `whoami` to identify the current user and device. When you edit a change, it broadcasts your presence. Other users see warnings like:
231231-232232-```
233233-Warning: This change is currently being edited by alice@laptop
234234-```
235235-236236-Presence entries expire after 5 minutes of inactivity.
237237-238238-## Configuration
239239-240240-### .jj/forge.toml
241241-242242-Created by `jjf link`, stores the forge URL:
243243-244244-```toml
245245-[forge]
246246-url = "https://forge.example.com/org/myrepo"
247247-```
248248-249249-### .jj/forge-queue.json
250250-251251-Stores queued operations when offline:
252252-253253-```json
254254-{
255255- "operations": [
256256- {
257257- "type": "change_updated",
258258- "record": { ... },
259259- "timestamp": "2024-01-01T00:00:00Z"
260260- }
261261- ]
262262-}
263263-```
264264-265265-### .jj/forge-offline
266266-267267-Marker file indicating offline mode. Removed when connection is restored.
268268-269269-## API Reference
270270-271271-### REST Endpoints
272272-273273-| Method | Path | Description |
274274-|--------|------|-------------|
275275-| POST | `/api/auth/login` | Authenticate and get token |
276276-| GET | `/api/auth/me` | Get current user |
277277-| GET | `/api/repos` | List repositories |
278278-| POST | `/api/repos` | Create repository |
279279-| GET | `/api/repos/:id` | Get repository details |
280280-| GET | `/api/repos/:id/changes` | List changes |
281281-| GET | `/api/repos/:id/changes/:cid` | Get specific change |
282282-| GET | `/api/repos/:id/bookmarks` | List bookmarks |
283283-| POST | `/api/repos/:id/bookmarks` | Move bookmark |
284284-| GET | `/api/repos/:id/presence` | Get active presence |
285285-| GET | `/api/repos/:id/content/:hash` | Fetch content by hash |
286286-287287-### WebSocket Endpoints
288288-289289-| Path | Description |
290290-|------|-------------|
291291-| `/sync/:repo_id` | Bidirectional Yrs sync |
292292-| `/events/:repo_id` | Server-sent events for notifications |
293293-294294-## License
295295-296296-MIT OR Apache-2.0
···11-//! Tandem CLI library
22-//!
33-//! This module provides the core functionality for the Tandem CLI
44-55-pub mod repo;
66-pub mod daemon;
77-pub mod presence;
88-pub mod link;
99-pub mod clone;
1010-pub mod offline;
1111-pub mod alias;
1212-pub mod content;
-79
crates/tandem-cli/src/link.rs
···11-use std::path::Path;
22-use crate::repo::{JjRepo, ForgeConfig, ForgeSettings, RepoError};
33-44-#[derive(Debug, thiserror::Error)]
55-pub enum LinkError {
66- #[error("Repository error: {0}")]
77- Repo(#[from] RepoError),
88- #[error("Already linked to forge: {0}")]
99- AlreadyLinked(String),
1010- #[error("Forge unreachable: {0}")]
1111- Unreachable(String),
1212- #[error("Authentication failed")]
1313- AuthFailed,
1414- #[error("HTTP error: {0}")]
1515- Http(String),
1616-}
1717-1818-pub async fn link_repo(
1919- repo_path: &Path,
2020- forge_url: &str,
2121- token: Option<&str>,
2222-) -> Result<(), LinkError> {
2323- let repo = JjRepo::open(repo_path)?;
2424-2525- if let Some(existing) = repo.forge_config()? {
2626- return Err(LinkError::AlreadyLinked(existing.forge.url));
2727- }
2828-2929- let url = normalize_forge_url(forge_url);
3030-3131- test_forge_connection(&url, token).await?;
3232-3333- let config = ForgeConfig {
3434- forge: ForgeSettings {
3535- url: url.clone(),
3636- },
3737- };
3838- repo.set_forge_config(&config)?;
3939-4040- if token.is_some() {
4141- println!("Token provided - in production, this would be stored in system keychain");
4242- }
4343-4444- println!("✓ Linked to forge: {}", url);
4545- println!(" Run 'jjf daemon start' to begin syncing");
4646-4747- Ok(())
4848-}
4949-5050-fn normalize_forge_url(url: &str) -> String {
5151- let mut url = url.to_string();
5252-5353- if !url.starts_with("http://") && !url.starts_with("https://") {
5454- url = format!("https://{}", url);
5555- }
5656-5757- url.trim_end_matches('/').to_string()
5858-}
5959-6060-async fn test_forge_connection(url: &str, token: Option<&str>) -> Result<(), LinkError> {
6161- let client = reqwest::Client::new();
6262-6363- let mut req = client.get(format!("{}/health", url));
6464- if let Some(token) = token {
6565- req = req.header("Authorization", format!("Bearer {}", token));
6666- }
6767-6868- let response = req.send().await
6969- .map_err(|e| LinkError::Unreachable(e.to_string()))?;
7070-7171- if !response.status().is_success() {
7272- if response.status().as_u16() == 401 {
7373- return Err(LinkError::AuthFailed);
7474- }
7575- return Err(LinkError::Http(format!("Status: {}", response.status())));
7676- }
7777-7878- Ok(())
7979-}
-238
crates/tandem-cli/src/main.rs
···11-//! Tandem CLI (jjf)
22-//!
33-//! Command-line interface for the Tandem Forge
44-55-use clap::{Parser, Subcommand};
66-use tandem_cli::repo::{JjRepo, ForgeConfig, ForgeSettings};
77-use std::env;
88-use std::path::PathBuf;
99-1010-#[derive(Parser)]
1111-#[command(name = "jjf")]
1212-#[command(about = "Jujutsu Forge CLI - Manage code reviews and changes", long_about = None)]
1313-struct Cli {
1414- #[command(subcommand)]
1515- command: Commands,
1616-}
1717-1818-#[derive(Subcommand)]
1919-enum Commands {
2020- /// Initialize a new repository
2121- Init {
2222- /// Repository name
2323- #[arg(short, long)]
2424- name: String,
2525- },
2626- /// List all changes
2727- List,
2828- /// Show status
2929- Status,
3030- /// Link this repository to a forge
3131- Link {
3232- /// Forge URL (e.g., https://forge.example.com/org/repo)
3333- url: String,
3434-3535- /// Auth token (if not provided, will prompt or use keychain)
3636- #[arg(long)]
3737- token: Option<String>,
3838- },
3939- /// Clone a repository from a forge
4040- Clone {
4141- /// Forge URL (e.g., https://forge.example.com/org/repo)
4242- url: String,
4343-4444- /// Target directory (defaults to repo name)
4545- #[arg(short, long)]
4646- directory: Option<PathBuf>,
4747-4848- /// Auth token
4949- #[arg(long)]
5050- token: Option<String>,
5151- },
5252- /// Daemon management
5353- Daemon {
5454- #[command(subcommand)]
5555- action: DaemonAction,
5656- },
5757- /// Wrapper for jj with presence warnings (use as: alias jj='jjf alias')
5858- Alias {
5959- /// Arguments to pass to jj
6060- #[arg(trailing_var_arg = true, allow_hyphen_values = true)]
6161- args: Vec<String>,
6262- },
6363-}
6464-6565-#[derive(Subcommand)]
6666-enum DaemonAction {
6767- /// Start daemon in foreground
6868- Start,
6969- /// Check daemon status
7070- Status,
7171-}
7272-7373-#[tokio::main]
7474-async fn main() {
7575- let cli = Cli::parse();
7676-7777- match cli.command {
7878- Commands::Alias { args } => {
7979- use tandem_cli::alias;
8080- match alias::run_alias(args).await {
8181- Ok(code) => std::process::exit(code),
8282- Err(e) => {
8383- eprintln!("Error: {}", e);
8484- std::process::exit(1);
8585- }
8686- }
8787- }
8888- _ => {
8989- let result = match cli.command {
9090- Commands::Init { name } => handle_init(&name),
9191- Commands::List => handle_list(),
9292- Commands::Status => handle_status(),
9393- Commands::Link { url, token } => handle_link(&url, token.as_deref()).await,
9494- Commands::Clone { url, directory, token } => handle_clone(&url, directory.as_deref(), token.as_deref()).await,
9595- Commands::Daemon { action } => handle_daemon(action).await,
9696- Commands::Alias { .. } => unreachable!(),
9797- };
9898-9999- if let Err(e) = result {
100100- eprintln!("Error: {}", e);
101101- std::process::exit(1);
102102- }
103103- }
104104- }
105105-}
106106-107107-fn handle_init(name: &str) -> Result<(), Box<dyn std::error::Error>> {
108108- let current_dir = env::current_dir()?;
109109-110110- // Check if .jj directory exists
111111- let jj_dir = current_dir.join(".jj");
112112- if !jj_dir.exists() {
113113- return Err("Not a jj repository. Run 'jj init' or 'jj git clone' first.".into());
114114- }
115115-116116- let repo = JjRepo::open(¤t_dir)?;
117117-118118- // Check if forge is already configured
119119- if let Some(existing_config) = repo.forge_config()? {
120120- println!("Forge already configured: {}", existing_config.forge.url);
121121- return Ok(());
122122- }
123123-124124- // Create forge configuration
125125- let config = ForgeConfig {
126126- forge: ForgeSettings {
127127- url: format!("https://forge.example.com/{}", name),
128128- },
129129- };
130130-131131- repo.set_forge_config(&config)?;
132132- println!("Initialized forge configuration for repository: {}", name);
133133- println!("Forge URL: {}", config.forge.url);
134134-135135- Ok(())
136136-}
137137-138138-fn handle_list() -> Result<(), Box<dyn std::error::Error>> {
139139- let current_dir = env::current_dir()?;
140140- let repo = JjRepo::open(¤t_dir)?;
141141-142142- let changes = repo.list_changes()?;
143143-144144- if changes.is_empty() {
145145- println!("No changes found.");
146146- } else {
147147- println!("Changes:");
148148- for change in changes {
149149- println!(" {} - {}", change.id, change.description);
150150- }
151151- }
152152-153153- Ok(())
154154-}
155155-156156-fn handle_status() -> Result<(), Box<dyn std::error::Error>> {
157157- let current_dir = env::current_dir()?;
158158-159159- // Check if we're in a jj repository
160160- let jj_dir = current_dir.join(".jj");
161161- if !jj_dir.exists() {
162162- println!("Not a jj repository");
163163- return Ok(());
164164- }
165165-166166- let repo = JjRepo::open(¤t_dir)?;
167167-168168- // Check forge configuration
169169- match repo.forge_config()? {
170170- Some(config) => {
171171- println!("Repository: {}", repo.path().display());
172172- println!("Forge URL: {}", config.forge.url);
173173- println!("Status: Connected");
174174- }
175175- None => {
176176- println!("Repository: {}", repo.path().display());
177177- println!("Forge: Not configured");
178178- println!("Run 'jjf init --name <repo-name>' to configure");
179179- }
180180- }
181181-182182- Ok(())
183183-}
184184-185185-async fn handle_link(url: &str, token: Option<&str>) -> Result<(), Box<dyn std::error::Error>> {
186186- use tandem_cli::link;
187187-188188- let cwd = env::current_dir()?;
189189- link::link_repo(&cwd, url, token).await?;
190190- Ok(())
191191-}
192192-193193-async fn handle_clone(
194194- url: &str,
195195- directory: Option<&std::path::Path>,
196196- token: Option<&str>,
197197-) -> Result<(), Box<dyn std::error::Error>> {
198198- use tandem_cli::clone;
199199-200200- clone::clone_repo(url, directory, token).await?;
201201- Ok(())
202202-}
203203-204204-async fn handle_daemon(action: DaemonAction) -> Result<(), Box<dyn std::error::Error>> {
205205- use tandem_cli::daemon;
206206-207207- let current_dir = env::current_dir()?;
208208- let repo = JjRepo::open(¤t_dir)?;
209209-210210- let config = repo.forge_config()?
211211- .ok_or("Forge not configured. Run 'jjf init --name <repo-name>' first.")?;
212212-213213- match action {
214214- DaemonAction::Start => {
215215- println!("Starting daemon...");
216216- println!("Repo: {}", repo.path().display());
217217- println!("Forge: {}", config.forge.url);
218218-219219- let handle = daemon::spawn_daemon(
220220- repo.path().to_path_buf(),
221221- config.forge.url.clone()
222222- );
223223-224224- println!("Daemon started. Press Ctrl+C to stop.");
225225-226226- tokio::signal::ctrl_c().await?;
227227-228228- println!("\nShutting down daemon...");
229229- handle.shutdown().await?;
230230- println!("Daemon stopped.");
231231- }
232232- DaemonAction::Status => {
233233- println!("Daemon status: Not implemented yet");
234234- }
235235- }
236236-237237- Ok(())
238238-}
-161
crates/tandem-cli/src/offline.rs
···11-use std::path::{Path, PathBuf};
22-use serde::{Serialize, Deserialize};
33-use chrono::{DateTime, Utc};
44-use tandem_core::types::{ChangeRecord, ChangeId};
55-66-#[derive(Debug, thiserror::Error)]
77-pub enum OfflineError {
88- #[error("IO error: {0}")]
99- Io(#[from] std::io::Error),
1010- #[error("Serialization error: {0}")]
1111- Serialization(#[from] serde_json::Error),
1212-}
1313-1414-/// Types of operations that can be queued offline
1515-#[derive(Debug, Clone, Serialize, Deserialize)]
1616-#[serde(tag = "type", rename_all = "snake_case")]
1717-pub enum QueuedOperation {
1818- /// A change was created or modified
1919- ChangeUpdated {
2020- record: ChangeRecord,
2121- timestamp: DateTime<Utc>,
2222- },
2323- /// A bookmark was moved
2424- BookmarkMoved {
2525- name: String,
2626- target: ChangeId,
2727- timestamp: DateTime<Utc>,
2828- },
2929- /// Presence was updated
3030- PresenceUpdated {
3131- change_id: ChangeId,
3232- timestamp: DateTime<Utc>,
3333- },
3434-}
3535-3636-/// Queue for offline operations
3737-#[derive(Debug, Default, Serialize, Deserialize)]
3838-pub struct OperationQueue {
3939- operations: Vec<QueuedOperation>,
4040-}
4141-4242-impl OperationQueue {
4343- pub fn new() -> Self {
4444- Self::default()
4545- }
4646-4747- /// Load queue from disk
4848- pub fn load(repo_path: &Path) -> Result<Self, OfflineError> {
4949- let queue_path = Self::queue_path(repo_path);
5050-5151- if !queue_path.exists() {
5252- return Ok(Self::new());
5353- }
5454-5555- let content = std::fs::read_to_string(&queue_path)?;
5656- let queue: Self = serde_json::from_str(&content)?;
5757- Ok(queue)
5858- }
5959-6060- /// Save queue to disk
6161- pub fn save(&self, repo_path: &Path) -> Result<(), OfflineError> {
6262- let queue_path = Self::queue_path(repo_path);
6363-6464- // Create parent directory if needed
6565- if let Some(parent) = queue_path.parent() {
6666- std::fs::create_dir_all(parent)?;
6767- }
6868-6969- let content = serde_json::to_string_pretty(self)?;
7070- std::fs::write(&queue_path, content)?;
7171- Ok(())
7272- }
7373-7474- /// Add operation to queue
7575- pub fn enqueue(&mut self, op: QueuedOperation) {
7676- self.operations.push(op);
7777- }
7878-7979- /// Get number of queued operations
8080- pub fn len(&self) -> usize {
8181- self.operations.len()
8282- }
8383-8484- /// Check if queue is empty
8585- pub fn is_empty(&self) -> bool {
8686- self.operations.is_empty()
8787- }
8888-8989- /// Take all operations (clears queue)
9090- pub fn drain(&mut self) -> Vec<QueuedOperation> {
9191- std::mem::take(&mut self.operations)
9292- }
9393-9494- /// Clear the queue and delete the file
9595- pub fn clear(&mut self, repo_path: &Path) -> Result<(), OfflineError> {
9696- self.operations.clear();
9797- let queue_path = Self::queue_path(repo_path);
9898- if queue_path.exists() {
9999- std::fs::remove_file(&queue_path)?;
100100- }
101101- Ok(())
102102- }
103103-104104- fn queue_path(repo_path: &Path) -> PathBuf {
105105- repo_path.join(".jj").join("forge-queue.json")
106106- }
107107-}
108108-109109-/// Replay queued operations to forge
110110-pub async fn replay_queue(
111111- repo_path: &Path,
112112- doc: &tandem_core::sync::ForgeDoc,
113113-) -> Result<usize, OfflineError> {
114114- let mut queue = OperationQueue::load(repo_path)?;
115115-116116- if queue.is_empty() {
117117- return Ok(0);
118118- }
119119-120120- let operations = queue.drain();
121121- let count = operations.len();
122122-123123- for op in operations {
124124- match op {
125125- QueuedOperation::ChangeUpdated { record, .. } => {
126126- doc.insert_change(&record);
127127- }
128128- QueuedOperation::BookmarkMoved { name, target, .. } => {
129129- doc.set_bookmark(&name, &target);
130130- }
131131- QueuedOperation::PresenceUpdated { .. } => {
132132- // Presence updates are ephemeral, skip old ones
133133- }
134134- }
135135- }
136136-137137- // Clear the queue file
138138- queue.clear(repo_path)?;
139139-140140- Ok(count)
141141-}
142142-143143-/// Check if we're in offline mode (no connection to forge)
144144-pub fn is_offline(repo_path: &Path) -> bool {
145145- // Check for offline marker file
146146- let marker = repo_path.join(".jj").join("forge-offline");
147147- marker.exists()
148148-}
149149-150150-/// Set offline mode
151151-pub fn set_offline(repo_path: &Path, offline: bool) -> Result<(), OfflineError> {
152152- let marker = repo_path.join(".jj").join("forge-offline");
153153-154154- if offline {
155155- std::fs::write(&marker, "")?;
156156- } else if marker.exists() {
157157- std::fs::remove_file(&marker)?;
158158- }
159159-160160- Ok(())
161161-}
-113
crates/tandem-cli/src/presence.rs
···11-use tandem_core::types::{ChangeId, PresenceInfo};
22-use tandem_core::sync::ForgeDoc;
33-use chrono::{Utc, Duration};
44-use std::sync::Arc;
55-use tokio::sync::RwLock;
66-77-/// Presence manager for tracking who's editing what
88-pub struct PresenceManager {
99- doc: Arc<RwLock<ForgeDoc>>,
1010- user_id: String,
1111- device: String,
1212-}
1313-1414-impl PresenceManager {
1515- pub fn new(doc: Arc<RwLock<ForgeDoc>>, user_id: String, device: String) -> Self {
1616- Self { doc, user_id, device }
1717- }
1818-1919- /// Update our presence (call when user edits a change)
2020- pub async fn update_presence(&self, change_id: &ChangeId) {
2121- let info = PresenceInfo {
2222- user_id: self.user_id.clone(),
2323- change_id: *change_id,
2424- device: self.device.clone(),
2525- timestamp: Utc::now(),
2626- };
2727-2828- let doc = self.doc.read().await;
2929- doc.update_presence(&info);
3030- }
3131-3232- /// Clear our presence (call when leaving a change)
3333- pub async fn clear_presence(&self) {
3434- let doc = self.doc.read().await;
3535- doc.remove_presence(&self.user_id);
3636- }
3737-3838- /// Get all active presence (excluding stale entries > 5 min)
3939- pub async fn get_active_presence(&self) -> Vec<PresenceInfo> {
4040- let doc = self.doc.read().await;
4141- let all = doc.get_all_presence();
4242- let cutoff = Utc::now() - Duration::minutes(5);
4343-4444- all.into_iter()
4545- .filter(|p| p.timestamp > cutoff)
4646- .collect()
4747- }
4848-4949- /// Check if anyone else is editing this change
5050- pub async fn check_conflict(&self, change_id: &ChangeId) -> Vec<PresenceInfo> {
5151- self.get_active_presence().await
5252- .into_iter()
5353- .filter(|p| &p.change_id == change_id && p.user_id != self.user_id)
5454- .collect()
5555- }
5656-}
5757-5858-/// Format presence warning message
5959-pub fn format_presence_warning(conflicts: &[PresenceInfo]) -> String {
6060- if conflicts.is_empty() {
6161- return String::new();
6262- }
6363-6464- if conflicts.len() == 1 {
6565- let p = &conflicts[0];
6666- format!(
6767- "⚠ This change is currently being edited by {}@{}",
6868- p.user_id, p.device
6969- )
7070- } else {
7171- let users: Vec<String> = conflicts
7272- .iter()
7373- .map(|p| format!("{}@{}", p.user_id, p.device))
7474- .collect();
7575- format!(
7676- "⚠ This change is currently being edited by: {}",
7777- users.join(", ")
7878- )
7979- }
8080-}
8181-8282-/// Format presence info for jj log output
8383-pub fn format_log_presence(presences: &[PresenceInfo], change_id: &ChangeId) -> Option<String> {
8484- let editing: Vec<&PresenceInfo> = presences
8585- .iter()
8686- .filter(|p| &p.change_id == change_id)
8787- .collect();
8888-8989- if editing.is_empty() {
9090- return None;
9191- }
9292-9393- if editing.len() == 1 {
9494- Some(format!("({} editing)", editing[0].user_id))
9595- } else {
9696- let count = editing.len();
9797- Some(format!("({} users editing)", count))
9898- }
9999-}
100100-101101-/// Prompt user to continue when there's a conflict
102102-pub fn prompt_continue(warning: &str) -> bool {
103103- use std::io::{self, Write};
104104-105105- println!("{}", warning);
106106- print!("Continue anyway? [y/N] ");
107107- io::stdout().flush().unwrap();
108108-109109- let mut input = String::new();
110110- io::stdin().read_line(&mut input).unwrap();
111111-112112- matches!(input.trim().to_lowercase().as_str(), "y" | "yes")
113113-}
-259
crates/tandem-cli/src/repo.rs
···11-use std::path::{Path, PathBuf};
22-use std::collections::HashMap;
33-use tandem_core::types::{Change, ChangeId, Identity, TreeHash};
44-use jj_lib::workspace::Workspace;
55-use jj_lib::settings::UserSettings;
66-use jj_lib::repo::{StoreFactories, Repo};
77-use jj_lib::revset::RevsetExpression;
88-use jj_lib::object_id::ObjectId;
99-use chrono::{DateTime, Utc};
1010-1111-/// Error type for repo operations
1212-#[derive(Debug, thiserror::Error)]
1313-pub enum RepoError {
1414- #[error("Repository not found at {0}")]
1515- NotFound(PathBuf),
1616- #[error("Not a jj repository")]
1717- NotJjRepo,
1818- #[error("IO error: {0}")]
1919- Io(#[from] std::io::Error),
2020- #[error("Internal error: {0}")]
2121- Internal(String),
2222-}
2323-2424-/// Wrapper around jj repository
2525-pub struct JjRepo {
2626- path: PathBuf,
2727- workspace: Workspace,
2828-}
2929-3030-impl JjRepo {
3131- /// Open a jj repository at the given path
3232- pub fn open(path: impl AsRef<Path>) -> Result<Self, RepoError> {
3333- let path = path.as_ref().to_path_buf();
3434-3535- // Check for .jj directory
3636- let jj_dir = path.join(".jj");
3737- if !jj_dir.exists() {
3838- return Err(RepoError::NotJjRepo);
3939- }
4040-4141- // Create default user settings (required by jj-lib)
4242- let config = jj_lib::config::StackedConfig::empty();
4343- let settings = UserSettings::from_config(config)
4444- .map_err(|e| RepoError::Internal(format!("Failed to create settings: {}", e)))?;
4545-4646- // Create store factories for loading the repository
4747- let store_factories = StoreFactories::default();
4848-4949- // Empty working copy factories map (use defaults)
5050- let wc_factories = HashMap::new();
5151-5252- // Load the workspace
5353- let workspace = Workspace::load(&settings, &path, &store_factories, &wc_factories)
5454- .map_err(|e| RepoError::Internal(format!("Failed to load workspace: {}", e)))?;
5555-5656- Ok(Self { path, workspace })
5757- }
5858-5959- /// Get repository root path
6060- pub fn path(&self) -> &Path {
6161- &self.path
6262- }
6363-6464- /// List all visible changes in the repository
6565- pub fn list_changes(&self) -> Result<Vec<Change>, RepoError> {
6666- let repo_loader = self.workspace.repo_loader();
6767- let repo = repo_loader
6868- .load_at_head()
6969- .map_err(|e| RepoError::Internal(format!("Failed to load repo: {}", e)))?;
7070-7171- // Get all visible commits (equivalent to "jj log")
7272- // Use revset to get all commits
7373- let revset_expression = RevsetExpression::all();
7474- let evaluated = revset_expression
7575- .evaluate(repo.as_ref())
7676- .map_err(|e| RepoError::Internal(format!("Failed to evaluate revset: {}", e)))?;
7777-7878- let mut changes = Vec::new();
7979- for commit_id_result in evaluated.iter() {
8080- let commit_id = commit_id_result
8181- .map_err(|e| RepoError::Internal(format!("Failed to iterate commits: {}", e)))?;
8282- let commit = repo
8383- .store()
8484- .get_commit(&commit_id)
8585- .map_err(|e| RepoError::Internal(format!("Failed to get commit: {}", e)))?;
8686-8787- changes.push(Self::convert_commit_to_change(&commit, repo.as_ref())?);
8888- }
8989-9090- Ok(changes)
9191- }
9292-9393- /// Convert jj-lib Commit to our Change type
9494- fn convert_commit_to_change(
9595- commit: &jj_lib::commit::Commit,
9696- repo: &jj_lib::repo::ReadonlyRepo,
9797- ) -> Result<Change, RepoError> {
9898- // Convert change_id (jj's stable ID) from bytes
9999- let change_id_bytes = commit.change_id().as_bytes();
100100- if change_id_bytes.len() != 32 {
101101- return Err(RepoError::Internal(format!(
102102- "Invalid change_id length: expected 32, got {}",
103103- change_id_bytes.len()
104104- )));
105105- }
106106- let mut change_id = [0u8; 32];
107107- change_id.copy_from_slice(change_id_bytes);
108108-109109- // Convert tree_id from jj to our TreeHash
110110- // For now, we'll use a simplified hash of the tree content
111111- // In jj 0.37, MergedTree::resolve() is async and complex to use here
112112- // We'll extract tree_id from the underlying backend commit via the store
113113- // FIXME: Implement proper tree ID extraction - for now use a placeholder based on change_id
114114- // This is a temporary workaround until we properly handle async tree resolution
115115- let mut tree_hash = [0u8; 20];
116116- // Use first 20 bytes of change_id as a temporary tree hash placeholder
117117- tree_hash.copy_from_slice(&change_id[..20]);
118118-119119- // Convert parent change_ids
120120- let parents = commit
121121- .parent_ids()
122122- .iter()
123123- .filter_map(|parent_commit_id| {
124124- // Look up parent commit to get its change_id
125125- repo.store()
126126- .get_commit(parent_commit_id)
127127- .ok()
128128- .and_then(|parent_commit| {
129129- let parent_change_id_bytes = parent_commit.change_id().as_bytes();
130130- if parent_change_id_bytes.len() == 32 {
131131- let mut parent_change_id = [0u8; 32];
132132- parent_change_id.copy_from_slice(parent_change_id_bytes);
133133- Some(ChangeId(parent_change_id))
134134- } else {
135135- None
136136- }
137137- })
138138- })
139139- .collect();
140140-141141- let author = Identity {
142142- name: Some(commit.author().name.clone()),
143143- email: commit.author().email.clone(),
144144- };
145145-146146- // Convert timestamp from jj's Timestamp to chrono DateTime
147147- let timestamp_millis = commit.author().timestamp.timestamp.0;
148148- let timestamp = DateTime::from_timestamp(timestamp_millis / 1000, 0)
149149- .unwrap_or_else(|| Utc::now());
150150-151151- Ok(Change {
152152- id: ChangeId(change_id),
153153- tree: TreeHash(tree_hash),
154154- parents,
155155- description: commit.description().to_string(),
156156- author,
157157- timestamp,
158158- })
159159- }
160160-161161- /// Get a specific change by ID
162162- pub fn get_change(&self, id: &ChangeId) -> Result<Option<Change>, RepoError> {
163163- let repo_loader = self.workspace.repo_loader();
164164- let repo = repo_loader
165165- .load_at_head()
166166- .map_err(|e| RepoError::Internal(format!("Failed to load repo: {}", e)))?;
167167-168168- // In jj, we need to find the commit with this change_id
169169- // We'll search through all commits to find one with matching change_id
170170- let revset_expression = RevsetExpression::all();
171171- let evaluated = revset_expression
172172- .evaluate(repo.as_ref())
173173- .map_err(|e| RepoError::Internal(format!("Failed to evaluate revset: {}", e)))?;
174174-175175- for commit_id_result in evaluated.iter() {
176176- let commit_id = commit_id_result
177177- .map_err(|e| RepoError::Internal(format!("Failed to iterate commits: {}", e)))?;
178178- let commit = repo
179179- .store()
180180- .get_commit(&commit_id)
181181- .map_err(|e| RepoError::Internal(format!("Failed to get commit: {}", e)))?;
182182-183183- // Check if this commit's change_id matches
184184- let commit_change_id_bytes = commit.change_id().as_bytes();
185185- if commit_change_id_bytes == id.0.as_slice() {
186186- return Ok(Some(Self::convert_commit_to_change(&commit, repo.as_ref())?));
187187- }
188188- }
189189-190190- Ok(None)
191191- }
192192-193193- /// Get the current working copy change ID
194194- pub fn working_copy_change_id(&self) -> Result<Option<ChangeId>, RepoError> {
195195- let repo_loader = self.workspace.repo_loader();
196196- let repo = repo_loader
197197- .load_at_head()
198198- .map_err(|e| RepoError::Internal(format!("Failed to load repo: {}", e)))?;
199199-200200- // Get the working copy commit ID from the op store
201201- // In jj, we need to get it from the operation view
202202- let view = repo.view();
203203- let wc_commit_id = view
204204- .get_wc_commit_id(self.workspace.workspace_name())
205205- .ok_or_else(|| RepoError::Internal("No working copy commit for this workspace".to_string()))?;
206206-207207- // Load the commit to get its change_id
208208- let commit = repo
209209- .store()
210210- .get_commit(wc_commit_id)
211211- .map_err(|e| RepoError::Internal(format!("Failed to get working copy commit: {}", e)))?;
212212-213213- // Extract the change_id
214214- let change_id_bytes = commit.change_id().as_bytes();
215215- if change_id_bytes.len() != 32 {
216216- return Err(RepoError::Internal(format!(
217217- "Invalid change_id length: expected 32, got {}",
218218- change_id_bytes.len()
219219- )));
220220- }
221221- let mut change_id = [0u8; 32];
222222- change_id.copy_from_slice(change_id_bytes);
223223-224224- Ok(Some(ChangeId(change_id)))
225225- }
226226-227227- /// Check if forge is configured for this repo
228228- pub fn forge_config(&self) -> Result<Option<ForgeConfig>, RepoError> {
229229- let config_path = self.path.join(".jj").join("forge.toml");
230230- if !config_path.exists() {
231231- return Ok(None);
232232- }
233233-234234- let content = std::fs::read_to_string(&config_path)?;
235235- let config: ForgeConfig = toml::from_str(&content)
236236- .map_err(|e| RepoError::Internal(e.to_string()))?;
237237- Ok(Some(config))
238238- }
239239-240240- /// Write forge configuration
241241- pub fn set_forge_config(&self, config: &ForgeConfig) -> Result<(), RepoError> {
242242- let config_path = self.path.join(".jj").join("forge.toml");
243243- let content = toml::to_string_pretty(config)
244244- .map_err(|e| RepoError::Internal(e.to_string()))?;
245245- std::fs::write(&config_path, content)?;
246246- Ok(())
247247- }
248248-}
249249-250250-/// Forge configuration stored in .jj/forge.toml
251251-#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
252252-pub struct ForgeConfig {
253253- pub forge: ForgeSettings,
254254-}
255255-256256-#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
257257-pub struct ForgeSettings {
258258- pub url: String,
259259-}
···11-//! Tandem Core Library
22-//!
33-//! Shared data model and synchronization primitives for the Tandem project.
44-55-pub mod content;
66-pub mod model;
77-pub mod object_store;
88-pub mod sync;
99-pub mod types;
1010-1111-// Re-export model types
1212-pub use model::Repository;
1313-1414-// Re-export object store types
1515-pub use object_store::{
1616- hash_blob, FileMode, MemoryObjectStore, ObjectRef, ObjectStore, Tree, TreeEntry,
1717-};
1818-1919-// Re-export sync types
2020-pub use sync::*;
2121-2222-// Re-export core types
2323-pub use types::{
2424- BlobHash, Bookmark, BookmarkRules, Change, ChangeId, ChangeRecord, Identity, PresenceInfo,
2525- TreeHash,
2626-};
2727-2828-// Re-export content types
2929-pub use content::{ContentRequest, ContentResponse};
-13
crates/tandem-core/src/model.rs
···11-//! Data model for Tandem
22-33-use serde::{Deserialize, Serialize};
44-use uuid::Uuid;
55-use chrono::{DateTime, Utc};
66-77-#[derive(Debug, Clone, Serialize, Deserialize)]
88-pub struct Repository {
99- pub id: Uuid,
1010- pub name: String,
1111- pub created_at: DateTime<Utc>,
1212- pub updated_at: DateTime<Utc>,
1313-}
···11-CREATE TABLE IF NOT EXISTS repos (
22- id TEXT PRIMARY KEY,
33- name TEXT NOT NULL,
44- org TEXT NOT NULL,
55- created_at TEXT DEFAULT (datetime('now')),
66- UNIQUE(org, name)
77-);
88-99-CREATE TABLE IF NOT EXISTS users (
1010- id TEXT PRIMARY KEY,
1111- email TEXT UNIQUE NOT NULL,
1212- name TEXT,
1313- password_hash TEXT NOT NULL,
1414- created_at TEXT DEFAULT (datetime('now'))
1515-);
1616-1717-CREATE TABLE IF NOT EXISTS repo_access (
1818- repo_id TEXT REFERENCES repos(id) ON DELETE CASCADE,
1919- user_id TEXT REFERENCES users(id) ON DELETE CASCADE,
2020- role TEXT CHECK(role IN ('read', 'write', 'admin')) NOT NULL,
2121- PRIMARY KEY (repo_id, user_id)
2222-);
2323-2424-CREATE TABLE IF NOT EXISTS auth_tokens (
2525- token TEXT PRIMARY KEY,
2626- user_id TEXT REFERENCES users(id) ON DELETE CASCADE,
2727- expires_at TEXT NOT NULL
2828-);
2929-3030-CREATE INDEX IF NOT EXISTS idx_repo_access_user ON repo_access(user_id);
3131-CREATE INDEX IF NOT EXISTS idx_auth_tokens_user ON auth_tokens(user_id);
-159
crates/tandem-server/src/auth.rs
···11-use crate::{AppState, db::UserRow};
22-use axum::{
33- Json,
44- extract::{Request, State},
55- http::{StatusCode, header},
66- middleware::Next,
77- response::Response,
88-};
99-use chrono::{Duration, Utc};
1010-use serde::{Deserialize, Serialize};
1111-1212-#[derive(Debug, Serialize, Deserialize)]
1313-pub struct LoginRequest {
1414- pub email: String,
1515- pub password: String,
1616-}
1717-1818-#[derive(Debug, Serialize)]
1919-pub struct LoginResponse {
2020- pub token: String,
2121- pub expires_at: String,
2222-}
2323-2424-#[derive(Debug, Serialize)]
2525-pub struct UserResponse {
2626- pub id: String,
2727- pub email: String,
2828- pub name: Option<String>,
2929-}
3030-3131-#[derive(Debug, Clone)]
3232-pub struct AuthenticatedUser {
3333- pub id: String,
3434- pub email: String,
3535- pub name: Option<String>,
3636-}
3737-3838-impl From<UserRow> for AuthenticatedUser {
3939- fn from(row: UserRow) -> Self {
4040- Self {
4141- id: row.id,
4242- email: row.email,
4343- name: row.name,
4444- }
4545- }
4646-}
4747-4848-/// Login handler
4949-pub async fn login(
5050- State(state): State<AppState>,
5151- Json(req): Json<LoginRequest>,
5252-) -> Result<Json<LoginResponse>, StatusCode> {
5353- // Find user by email
5454- let user = state
5555- .db
5656- .get_user_by_email(&req.email)
5757- .await
5858- .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?
5959- .ok_or(StatusCode::UNAUTHORIZED)?;
6060-6161- // Verify password (simple comparison for now - use bcrypt in production)
6262- if !verify_password(&req.password, &user.password_hash) {
6363- return Err(StatusCode::UNAUTHORIZED);
6464- }
6565-6666- // Generate token
6767- let token = generate_token();
6868- let expires_at = Utc::now() + Duration::days(7);
6969-7070- // Store token
7171- state
7272- .db
7373- .create_token(&token, &user.id, expires_at)
7474- .await
7575- .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
7676-7777- Ok(Json(LoginResponse {
7878- token,
7979- expires_at: expires_at.to_rfc3339(),
8080- }))
8181-}
8282-8383-/// Get current user handler
8484-pub async fn get_me(user: AuthenticatedUser) -> Json<UserResponse> {
8585- Json(UserResponse {
8686- id: user.id,
8787- email: user.email,
8888- name: user.name,
8989- })
9090-}
9191-9292-/// Authentication middleware
9393-pub async fn auth_middleware(
9494- State(state): State<AppState>,
9595- mut request: Request,
9696- next: Next,
9797-) -> Result<Response, StatusCode> {
9898- // Extract bearer token
9999- let token = request
100100- .headers()
101101- .get(header::AUTHORIZATION)
102102- .and_then(|h| h.to_str().ok())
103103- .and_then(|h| h.strip_prefix("Bearer "))
104104- .ok_or(StatusCode::UNAUTHORIZED)?;
105105-106106- // Verify token and get user
107107- let user = state
108108- .db
109109- .verify_token(token)
110110- .await
111111- .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?
112112- .ok_or(StatusCode::UNAUTHORIZED)?;
113113-114114- // Add user to request extensions
115115- request
116116- .extensions_mut()
117117- .insert(AuthenticatedUser::from(user));
118118-119119- Ok(next.run(request).await)
120120-}
121121-122122-/// Extract authenticated user from request
123123-#[async_trait::async_trait]
124124-impl<S> axum::extract::FromRequestParts<S> for AuthenticatedUser
125125-where
126126- S: Send + Sync,
127127-{
128128- type Rejection = StatusCode;
129129-130130- async fn from_request_parts(
131131- parts: &mut axum::http::request::Parts,
132132- _state: &S,
133133- ) -> Result<Self, Self::Rejection> {
134134- parts
135135- .extensions
136136- .get::<AuthenticatedUser>()
137137- .cloned()
138138- .ok_or(StatusCode::UNAUTHORIZED)
139139- }
140140-}
141141-142142-fn generate_token() -> String {
143143- use rand::Rng;
144144- let mut rng = rand::thread_rng();
145145- let bytes: [u8; 32] = rng.r#gen();
146146- hex::encode(bytes)
147147-}
148148-149149-fn verify_password(password: &str, hash: &str) -> bool {
150150- // Simple comparison for prototype - use bcrypt in production
151151- // Hash is just the password for now
152152- password == hash
153153-}
154154-155155-/// Hash a password (for user creation)
156156-pub fn hash_password(password: &str) -> String {
157157- // Simple passthrough for prototype - use bcrypt in production
158158- password.to_string()
159159-}
-110
crates/tandem-server/src/authz.rs
···11-use axum::http::StatusCode;
22-use crate::{AppState, auth::AuthenticatedUser};
33-44-/// Role levels (ordered by permission level)
55-#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
66-pub enum Role {
77- Read = 0,
88- Write = 1,
99- Admin = 2,
1010-}
1111-1212-impl Role {
1313- pub fn from_str(s: &str) -> Option<Self> {
1414- match s {
1515- "read" => Some(Role::Read),
1616- "write" => Some(Role::Write),
1717- "admin" => Some(Role::Admin),
1818- _ => None,
1919- }
2020- }
2121-2222- pub fn as_str(&self) -> &'static str {
2323- match self {
2424- Role::Read => "read",
2525- Role::Write => "write",
2626- Role::Admin => "admin",
2727- }
2828- }
2929-}
3030-3131-/// Check if user has at least the required role for a repo
3232-pub async fn check_role(
3333- state: &AppState,
3434- user: &AuthenticatedUser,
3535- repo_id: &str,
3636- required: Role,
3737-) -> Result<(), StatusCode> {
3838- let role_str = state.db.get_user_role(&user.id, repo_id).await
3939- .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
4040-4141- let role = role_str
4242- .and_then(|s| Role::from_str(&s))
4343- .ok_or(StatusCode::FORBIDDEN)?;
4444-4545- if role >= required {
4646- Ok(())
4747- } else {
4848- Err(StatusCode::FORBIDDEN)
4949- }
5050-}
5151-5252-/// Helper to require read access
5353-pub async fn require_read(
5454- state: &AppState,
5555- user: &AuthenticatedUser,
5656- repo_id: &str,
5757-) -> Result<(), StatusCode> {
5858- check_role(state, user, repo_id, Role::Read).await
5959-}
6060-6161-/// Helper to require write access
6262-pub async fn require_write(
6363- state: &AppState,
6464- user: &AuthenticatedUser,
6565- repo_id: &str,
6666-) -> Result<(), StatusCode> {
6767- check_role(state, user, repo_id, Role::Write).await
6868-}
6969-7070-/// Helper to require admin access
7171-pub async fn require_admin(
7272- state: &AppState,
7373- user: &AuthenticatedUser,
7474- repo_id: &str,
7575-) -> Result<(), StatusCode> {
7676- check_role(state, user, repo_id, Role::Admin).await
7777-}
7878-7979-/// Check if a bookmark is protected
8080-pub async fn is_bookmark_protected(
8181- state: &AppState,
8282- repo_id: &str,
8383- bookmark_name: &str,
8484-) -> Result<bool, StatusCode> {
8585- let doc = state.docs.get_or_load(repo_id).await
8686- .map_err(|_| StatusCode::NOT_FOUND)?;
8787-8888- let doc = doc.read().await;
8989- let _bookmarks = doc.get_all_bookmarks();
9090-9191- // For now, consider bookmarks named "main" or "master" as protected
9292- // TODO: Make this configurable per-repo
9393- Ok(matches!(bookmark_name, "main" | "master"))
9494-}
9595-9696-/// Check if user can move a bookmark
9797-pub async fn can_move_bookmark(
9898- state: &AppState,
9999- user: &AuthenticatedUser,
100100- repo_id: &str,
101101- bookmark_name: &str,
102102-) -> Result<(), StatusCode> {
103103- let protected = is_bookmark_protected(state, repo_id, bookmark_name).await?;
104104-105105- if protected {
106106- require_admin(state, user, repo_id).await
107107- } else {
108108- require_write(state, user, repo_id).await
109109- }
110110-}