OXYGENHUB

Bare repositories and an embeddable protocol server for Oxygenhub e02bf0cd0533

oxy init --bare creates a repository with no workspace and no working copy;
log, log_for_path, blame_in and log_state read any state. oxy-server gains
Backend, Server, Endpoint and Access so a host resolves and authorises
repository names itself; oxy serve is DirBackend on the same server.
Author
Andy McCall @andymccall <[email protected]>
When
· 2026-09-27 16:44 UTC
Signature
✔ signed by Andy McCall
Hash
e02bf0cd053375a716dfb7dbb83f95f24a868aa1b2536787cb17ada500e284df
Depends on
132b884b9a8d 2863456a0f6d 2b5ee371060e 2b872d074ddb 4dee277c327a 4e88e774777a 50a046e439e9 750251b21a96 7a716eb94dee 7b9809e67c3d 94084087a876 957fee30826e 96f7610fa666 982864068a59 b3c5a59a875a c6aa5bd9d1c8 ee06a6c557b7 fce44ae9ad29 ff0af1ee299d
Depended on by
80b069facc69 544d0541a7e5 e30c081a5064 f3811eb20bea ee130dd9eef2 67a4fe1aa2d5 c2e1d09047db 79b5a90ffd43 170bb436e1d5 d069701a4997 1ec3f008e96a 14edbaaf5291 ef26f9a28790 073a2115995f 4d6c9c1b358c 69816d688300 be451356e3ef 8c30d814f2cc f8c695764c95 e8d38e47e9d7 bc489b6c7f69 89a1d0243544 d42369abf180 eae595568832 84b9e9ac0ab4 be0161a88dd7 76f752503e7e 119bbf50cb86 7aa2dc9369e3 b0e64e81e4fb 05e71ed6cdf1 75167672ebcd
In bookmarks
agents-in-readme bare-hashes carried-not-removed carry-identical doctor-trust identical-sides keyless-tags main merge-remote-bookmark only-help-103 paths-for-resolutions-and-amend push-notes show-stat subrepo-pin-state switch-says
In tags
v0.1.0 v0.10.0 v0.11.0 v0.2.0 v0.3.0 v0.3.1 v0.4.0 v0.4.1 v0.4.2 v0.4.3 v0.4.4 v0.4.5 v0.4.6 v0.5.0 v0.5.1 v0.6.0 v0.7.0 v0.8.0 v0.9.0
Edits
49 atoms, 0 tree operations, 10 files

Browse the tree at this change

modified crates/oxy-core/src/repo/inspect.rs +14 −3
…
53 unchanged lines
54 54
    /// the working copy's base state (spec §27).
55 55
    pub fn blame(&self, path: &str) -> Result<Vec<BlameLine>> {
56 56
        let snap = self.snapshot("oxy blame")?;
57 -
        let Some(f) = snap.base_snapshot.files.get(path) else { return fail(format!("{path} is not in the working copy's base state")) };
57 +
        self.blame_state(path, &snap.base, &snap.base_snapshot, "the working copy's base state")
58 +
    }
59 +
60 +
    /// Blame `path` in the state with these heads (hosted repositories).
61 +
    pub fn blame_in(&self, path: &str, heads: &[ChangeHash]) -> Result<Vec<BlameLine>> {
62 +
        let state = self.load_state(heads)?;
63 +
        let snapshot = state.materialize();
64 +
        self.blame_state(path, &state, &snapshot, "that state")
65 +
    }
66 +
67 +
    fn blame_state(&self, path: &str, state: &crate::state::State, snapshot: &crate::state::Snapshot, what: &str) -> Result<Vec<BlameLine>> {
68 +
        let Some(f) = snapshot.files.get(path) else { return fail(format!("{path} is not in {what}")) };
58 69
        if !matches!(f.content, FileContent::Text { .. }) {
59 70
            return fail(format!("{path} is not a text file"));
60 71
        }
61 72
        let mut out = Vec::new();
62 -
        for (i, ((hash, index), text)) in snap.base.live_lines(&f.node).into_iter().enumerate() {
63 -
            let meta = snap.base.get(&hash);
73 +
        for (i, ((hash, index), text)) in state.live_lines(&f.node).into_iter().enumerate() {
74 +
            let meta = state.get(&hash);
64 75
            let author = match meta {
65 76
                Some(m) if !m.author.is_zero() => self.store.get_identity(&m.author).map(|id| id.name).unwrap_or_else(|_| m.author.short(12)),
66 77
                Some(m) => m.message.lines().rev().find_map(|l| l.strip_prefix("Git-Author: ")).map(|a| a.split('<').next().unwrap_or(a).trim().to_string()).unwrap_or_else(|| "unknown".into()),
…
195 unchanged lines
modified crates/oxy-core/src/repo/mod.rs +76 −10
…
135 unchanged lines
136 136
    oxy: PathBuf,
137 137
    store: Store,
138 138
    workspace: WorkspaceId,
139 +
    /// A hosted repository with no working copy (spec §12.2, hub spec §4.1).
140 +
    bare: bool,
139 141
    keys_dir: PathBuf,
140 142
    user_config: PathBuf,
141 143
    pub config: Config,
142 144
}
143 145
146 +
/// Marker file of a bare repository, inside `.oxy/`.
147 +
const BARE_MARKER: &str = "bare";
148 +
144 149
/// The user's oxy directory: `$OXY_HOME`, else `$XDG_CONFIG_HOME/oxy`, else
145 150
/// `~/.config/oxy`. It holds `config.toml` (spec §19) and `keys/`.
146 151
pub fn default_home() -> PathBuf {
…
50 unchanged lines
197 202
        let workspace = WorkspaceId::random();
198 203
        write_atomic(&oxy.join("workspace"), workspace.to_hex().as_bytes())?;
199 204
        let store = Store::open(oxy.join("objects"))?;
200 -
        let repo = Repo { git_export_skipped: Default::default(), root, oxy, store, workspace, keys_dir: default_keys_dir(), user_config: default_user_config(), config };
205 +
        let repo = Repo { git_export_skipped: Default::default(), root, oxy, store, workspace, bare: false, keys_dir: default_keys_dir(), user_config: default_user_config(), config };
201 206
        repo.register_workspace(&workspace, &repo.root)?;
202 207
203 208
        let wc = repo.new_working_copy(vec![], "");
…
5 unchanged lines
209 214
        Ok(repo)
210 215
    }
211 216
217 +
    /// Create a bare repository: an object store, operation log and
218 +
    /// references with no workspace and no working copy, for hosting
219 +
    /// (hub spec §4.1). `root` holds `.oxy/` and nothing else.
220 +
    pub fn init_bare(root: &Path) -> Result<Repo> {
221 +
        let mut id = [0u8; 16];
222 +
        rand::rngs::OsRng.fill_bytes(&mut id);
223 +
        Self::init_bare_with_id(root, id)
224 +
    }
225 +
226 +
    pub fn init_bare_with_id(root: &Path, id: [u8; 16]) -> Result<Repo> {
227 +
        fs::create_dir_all(root)?;
228 +
        let root = root.canonicalize().unwrap_or_else(|_| root.to_path_buf());
229 +
        let oxy = root.join(OXY_DIR);
230 +
        if oxy.exists() {
231 +
            return fail(format!("{} is already an oxy repository", root.display()));
232 +
        }
233 +
        fs::create_dir_all(oxy.join("ops"))?;
234 +
        write_atomic(&oxy.join("format"), FORMAT_LINE.as_bytes())?;
235 +
        write_atomic(&oxy.join("id"), hex::encode(id).as_bytes())?;
236 +
        write_atomic(&oxy.join(BARE_MARKER), b"")?;
237 +
        let config = Config::load(Some(&default_user_config()), &oxy.join("config"))?;
238 +
        config.save(&oxy.join("config"))?;
239 +
        let store = Store::open(oxy.join("objects"))?;
240 +
        let repo = Repo { git_export_skipped: Default::default(), root, oxy, store, workspace: WorkspaceId::ZERO, bare: true, keys_dir: default_keys_dir(), user_config: default_user_config(), config };
241 +
        let mut view = View::default();
242 +
        view.bookmarks.insert(repo.config.bookmark.clone(), repo.store.put_state(&StateObject::new(vec![]))?);
243 +
        repo.record_op(&[], "oxy init --bare", view)?;
244 +
        Ok(repo)
245 +
    }
246 +
247 +
    /// Whether this repository has no working copy (`init_bare`).
248 +
    pub fn is_bare(&self) -> bool {
249 +
        self.bare
250 +
    }
251 +
212 252
    /// Open the repository containing `start` (searching upwards).
213 253
    pub fn open(start: &Path) -> Result<Repo> {
214 254
        let start = start.canonicalize()?;
…
12 unchanged lines
227 267
                }
228 268
                let config = Config::load(Some(&default_user_config()), &shared.join("config"))?;
229 269
                let store = Store::open(shared.join("objects"))?;
230 -
                let mut repo = Repo { git_export_skipped: Default::default(), root: d.to_path_buf(), oxy: shared, store, workspace, keys_dir: default_keys_dir(), user_config: default_user_config(), config };
270 +
                let mut repo = Repo { git_export_skipped: Default::default(), root: d.to_path_buf(), oxy: shared, store, workspace, bare: false, keys_dir: default_keys_dir(), user_config: default_user_config(), config };
231 271
                repo.apply_workspace_bookmark();
232 272
                return Ok(repo);
233 273
            }
234 274
            if oxy.is_dir() {
235 275
                let format = fs::read_to_string(oxy.join("format"))?;
236 276
                let config = Config::load(Some(&default_user_config()), &oxy.join("config"))?;
237 -
                let workspace = WorkspaceId::from_hex(fs::read_to_string(oxy.join("workspace"))?.trim())
238 -
                    .ok_or_else(|| RepoError::Msg("corrupt .oxy/workspace".into()))?;
277 +
                let bare = oxy.join(BARE_MARKER).is_file();
278 +
                let workspace = if bare {
279 +
                    WorkspaceId::ZERO
280 +
                } else {
281 +
                    WorkspaceId::from_hex(fs::read_to_string(oxy.join("workspace"))?.trim()).ok_or_else(|| RepoError::Msg("corrupt .oxy/workspace".into()))?
282 +
                };
239 283
                let store = Store::open(oxy.join("objects"))?;
240 -
                let mut repo = Repo { git_export_skipped: Default::default(), root: d.to_path_buf(), oxy, store, workspace, keys_dir: default_keys_dir(), user_config: default_user_config(), config };
241 -
                repo.apply_workspace_bookmark();
284 +
                let mut repo = Repo { git_export_skipped: Default::default(), root: d.to_path_buf(), oxy, store, workspace, bare, keys_dir: default_keys_dir(), user_config: default_user_config(), config };
285 +
                if !bare {
286 +
                    repo.apply_workspace_bookmark();
287 +
                }
242 288
                if format == FORMAT1_LINE {
243 289
                    repo.migrate_format1()?;
244 290
                } else if format != FORMAT_LINE {
…
222 unchanged lines
467 513
    }
468 514
469 515
    pub fn working_copy(&self) -> Result<(ChangeHash, Change)> {
516 +
        if self.bare {
517 +
            return fail("this is a bare repository: it has no working copy");
518 +
        }
470 519
        let view = self.view()?;
471 520
        let h = *view
472 521
            .workspaces
…
286 unchanged lines
759 808
impl Repo {
760 809
    /// Changes reachable from the bookmark and the working copy, newest first.
761 810
    pub fn log(&self) -> Result<Vec<LogEntry>> {
811 +
        let heads = self.bookmark_heads()?;
812 +
        if self.bare {
813 +
            return self.log_state(&heads);
814 +
        }
762 815
        let (wc_hash, _) = self.working_copy()?;
763 -
        let mut heads = self.bookmark_heads()?;
764 -
        heads.push(wc_hash);
765 -
        let state = self.load_state(&heads)?;
766 -
        let head_set: BTreeSet<ChangeHash> = self.bookmark_heads()?.into_iter().collect();
816 +
        let mut all = heads.clone();
817 +
        all.push(wc_hash);
818 +
        let state = self.load_state(&all)?;
819 +
        let head_set: BTreeSet<ChangeHash> = heads.into_iter().collect();
767 820
        let mut entries: Vec<LogEntry> = Vec::new();
768 821
        for (h, meta) in state.changes() {
769 822
            entries.push(LogEntry { hash: *h, change: meta.clone(), is_working_copy: *h == wc_hash, is_head: head_set.contains(h) });
…
2 unchanged lines
772 825
        Ok(entries)
773 826
    }
774 827
828 +
    /// The log of the state with these heads, newest first, with no working
829 +
    /// copy involved (for hosted repositories and any explicit state).
830 +
    pub fn log_state(&self, heads: &[ChangeHash]) -> Result<Vec<LogEntry>> {
831 +
        let state = self.load_state(heads)?;
832 +
        let head_set: BTreeSet<ChangeHash> = state.heads().into_iter().collect();
833 +
        let mut entries: Vec<LogEntry> = state
834 +
            .changes()
835 +
            .map(|(h, meta)| LogEntry { hash: *h, change: meta.clone(), is_working_copy: false, is_head: head_set.contains(h) })
836 +
            .collect();
837 +
        entries.sort_by(|a, b| b.change.hlc.cmp(&a.change.hlc).then(b.hash.cmp(&a.hash)));
838 +
        Ok(entries)
839 +
    }
840 +
775 841
    /// Resolve a spec to a set of heads: a tag name, the current bookmark's
776 842
    /// name, or a single change (`resolve_change`).
777 843
    pub fn resolve_heads(&self, spec: &str) -> Result<Vec<ChangeHash>> {
…
102 unchanged lines
modified crates/oxy-core/src/repo/snapshot.rs +18 −2
…
764 unchanged lines
765 765
    /// The log restricted to changes touching `path` (its node, so renames
766 766
    /// are followed), on the current bookmark.
767 767
    pub fn log_for_path(&self, path: &str) -> Result<Vec<LogEntry>> {
768 +
        let heads = self.bookmark_heads()?;
769 +
        if self.is_bare() {
770 +
            return self.log_for_path_in(path, &heads);
771 +
        }
768 772
        let entries = self.log()?;
769 773
        let (_, wc) = self.working_copy()?;
770 774
        let base_snapshot = self.load_state(&wc.deps)?.materialize();
771 -
        let heads = self.bookmark_heads()?;
772 775
        let state_snapshot = self.load_state(&heads)?.materialize();
773 776
        let node = base_snapshot.files.get(path).or_else(|| state_snapshot.files.get(path)).map(|f| f.node);
774 777
        let Some(node) = node else { return fail(format!("{path} is not tracked")) };
778 +
        Ok(self.entries_touching(entries, node))
779 +
    }
780 +
781 +
    /// The log of the state with these heads restricted to changes touching
782 +
    /// `path` (its node, so renames are followed).
783 +
    pub fn log_for_path_in(&self, path: &str, heads: &[ChangeHash]) -> Result<Vec<LogEntry>> {
784 +
        let entries = self.log_state(heads)?;
785 +
        let snapshot = self.load_state(heads)?.materialize();
786 +
        let Some(node) = snapshot.files.get(path).map(|f| f.node) else { return fail(format!("{path} is not in that state")) };
787 +
        Ok(self.entries_touching(entries, node))
788 +
    }
789 +
790 +
    fn entries_touching(&self, entries: Vec<LogEntry>, node: NodeId) -> Vec<LogEntry> {
775 791
        let mut out = Vec::new();
776 792
        for e in entries {
777 793
            let Ok(c) = self.store.get_change(&e.hash) else { continue };
…
1 unchanged lines
779 795
                out.push(e);
780 796
            }
781 797
        }
782 -
        Ok(out)
798 +
        out
783 799
    }
784 800
}
785 801
…
608 unchanged lines
modified crates/oxy-server/src/lib.rs +191 −111
…
5 unchanged lines
6 6
//!
7 7
//! Authentication: when bearer tokens are configured, `push` requires one;
8 8
//! reads are open. With no tokens configured everything is open (development).
9 +
//!
10 +
//! Hosts embed the protocol by implementing [`Backend`] (name resolution
11 +
//! and authorization) and routing to [`Server::handle`]; `router` is the
12 +
//! standalone server built on [`DirBackend`].
9 13
10 14
use axum::body::Bytes;
11 15
use axum::extract::{Path, State};
12 -
use axum::http::{header, HeaderMap, StatusCode};
16 +
use axum::http::{header, HeaderMap, Method, StatusCode};
13 17
use axum::response::{IntoResponse, Response};
14 -
use axum::routing::{get, post};
18 +
use axum::routing::any;
15 19
use axum::Router;
16 20
use oxy_core::protocol::{wire, CONTENT_TYPE};
17 21
use oxy_core::repo::Repo;
…
17 unchanged lines
35 39
    pub key: PathBuf,
36 40
}
37 41
38 -
struct App {
39 -
    cfg: ServerConfig,
40 -
    /// One lock per repository so concurrent pushes serialize.
42 +
/// Which kind of access a request needs (spec §15.1: reads may be open;
43 +
/// a push must be authenticated when any credential is configured).
44 +
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
45 +
pub enum Access {
46 +
    Read,
47 +
    Push,
48 +
}
49 +
50 +
/// What a host provides to the protocol server: how a repository name maps
51 +
/// to a repository on disk and who may do what. `oxy serve` uses
52 +
/// [`DirBackend`]; a hub supplies its own with accounts and roles.
53 +
pub trait Backend: Send + Sync + 'static {
54 +
    /// Open the repository called `name`, or fail with the status to
55 +
    /// answer. An unknown name is `404`; a name the caller may not see
56 +
    /// SHOULD also be `404`, so private repositories are not enumerable.
57 +
    fn open(&self, name: &str) -> Result<Repo, Failure>;
58 +
59 +
    /// Decide whether this request may perform `access` on `name`.
60 +
    fn authorize(&self, name: &str, access: Access, headers: &HeaderMap) -> Result<(), Failure>;
61 +
62 +
    /// Called after a push was processed, with its per-reference results
63 +
    /// (accepted or refused), for logs and cache invalidation.
64 +
    fn after_push(&self, _name: &str, _results: &[oxy_core::protocol::RefResult]) {}
65 +
}
66 +
67 +
/// A protocol server over any [`Backend`], serialising the pushes to each
68 +
/// repository. Hosts route requests to [`Server::handle`].
69 +
pub struct Server {
70 +
    backend: Box<dyn Backend>,
41 71
    locks: Mutex<HashMap<String, Arc<Mutex<()>>>>,
42 72
}
43 73
44 -
type Shared = Arc<App>;
45 -
46 -
struct Failure(StatusCode, String);
74 +
/// An error response: a status and a plain-text body.
75 +
#[derive(Debug)]
76 +
pub struct Failure(pub StatusCode, pub String);
47 77
48 78
impl IntoResponse for Failure {
49 79
    fn into_response(self) -> Response {
…
7 unchanged lines
57 87
    }
58 88
}
59 89
90 +
/// The wire protocol's endpoints (spec §16.1), named as in the URL.
91 +
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
92 +
pub enum Endpoint {
93 +
    Capabilities,
94 +
    Refs,
95 +
    Negotiate,
96 +
    Fetch,
97 +
    Objects,
98 +
    Checkpoint,
99 +
    Shallow,
100 +
    Push,
101 +
}
102 +
103 +
impl Endpoint {
104 +
    pub fn parse(name: &str) -> Option<Endpoint> {
105 +
        Some(match name {
106 +
            "capabilities" => Endpoint::Capabilities,
107 +
            "refs" => Endpoint::Refs,
108 +
            "negotiate" => Endpoint::Negotiate,
109 +
            "fetch" => Endpoint::Fetch,
110 +
            "objects" => Endpoint::Objects,
111 +
            "checkpoint" => Endpoint::Checkpoint,
112 +
            "shallow" => Endpoint::Shallow,
113 +
            "push" => Endpoint::Push,
114 +
            _ => return None,
115 +
        })
116 +
    }
117 +
118 +
    pub fn name(self) -> &'static str {
119 +
        match self {
120 +
            Endpoint::Capabilities => "capabilities",
121 +
            Endpoint::Refs => "refs",
122 +
            Endpoint::Negotiate => "negotiate",
123 +
            Endpoint::Fetch => "fetch",
124 +
            Endpoint::Objects => "objects",
125 +
            Endpoint::Checkpoint => "checkpoint",
126 +
            Endpoint::Shallow => "shallow",
127 +
            Endpoint::Push => "push",
128 +
        }
129 +
    }
130 +
131 +
    /// The HTTP method the endpoint answers to.
132 +
    pub fn method(self) -> Method {
133 +
        match self {
134 +
            Endpoint::Capabilities | Endpoint::Refs => Method::GET,
135 +
            _ => Method::POST,
136 +
        }
137 +
    }
138 +
139 +
    fn access(self) -> Access {
140 +
        match self {
141 +
            Endpoint::Push => Access::Push,
142 +
            _ => Access::Read,
143 +
        }
144 +
    }
145 +
}
146 +
60 147
fn ok(body: Vec<u8>) -> Response {
61 148
    ([(header::CONTENT_TYPE, CONTENT_TYPE)], body).into_response()
62 149
}
63 150
64 -
fn valid_repo_name(name: &str) -> bool {
151 +
pub fn valid_repo_name(name: &str) -> bool {
65 152
    !name.is_empty()
66 153
        && !name.starts_with('.')
67 154
        && name.chars().all(|c| c.is_ascii_alphanumeric() || matches!(c, '.' | '_' | '-'))
68 155
}
69 156
70 -
impl App {
157 +
impl Server {
158 +
    pub fn new(backend: impl Backend) -> Arc<Server> {
159 +
        Arc::new(Server { backend: Box::new(backend), locks: Mutex::new(HashMap::new()) })
160 +
    }
161 +
162 +
    pub fn backend(&self) -> &dyn Backend {
163 +
        self.backend.as_ref()
164 +
    }
165 +
166 +
    fn lock_for(&self, name: &str) -> Arc<Mutex<()>> {
167 +
        self.locks.lock().unwrap().entry(name.to_string()).or_default().clone()
168 +
    }
169 +
170 +
    /// Answer one protocol request for repository `name`. `method` is
171 +
    /// checked against the endpoint; the body is the raw CBOR request.
172 +
    pub async fn handle(self: &Arc<Self>, name: String, endpoint: Endpoint, method: &Method, headers: &HeaderMap, body: Bytes) -> Response {
173 +
        if *method != endpoint.method() {
174 +
            return Failure(StatusCode::METHOD_NOT_ALLOWED, format!("{} is {}", endpoint.name(), endpoint.method())).into_response();
175 +
        }
176 +
        if let Err(f) = self.backend.authorize(&name, endpoint.access(), headers) {
177 +
            return f.into_response();
178 +
        }
179 +
        let server = Arc::clone(self);
180 +
        let result = tokio::task::spawn_blocking(move || server.respond(&name, endpoint, body)).await;
181 +
        match result {
182 +
            Ok(Ok(r)) => r,
183 +
            Ok(Err(f)) => f.into_response(),
184 +
            Err(e) => Failure(StatusCode::INTERNAL_SERVER_ERROR, e.to_string()).into_response(),
185 +
        }
186 +
    }
187 +
188 +
    fn respond(&self, name: &str, endpoint: Endpoint, body: Bytes) -> Result<Response, Failure> {
189 +
        let r = self.backend.open(name)?;
190 +
        Ok(match endpoint {
191 +
            Endpoint::Capabilities => ok(wire::encode_capabilities(r.id()?)),
192 +
            Endpoint::Refs => ok(wire::encode_refs(r.id()?, &r.remote_refs()?)),
193 +
            Endpoint::Negotiate => {
194 +
                let (have, want) = wire::decode_negotiate(&body)?;
195 +
                ok(wire::encode_hashes(&r.negotiate(&have, &want)?))
196 +
            }
197 +
            Endpoint::Fetch => {
198 +
                let (changes, max_blob) = wire::decode_fetch(&body)?;
199 +
                ok(wire::encode_bundle(&r.bundle_for_filtered(&changes, max_blob)?))
200 +
            }
201 +
            Endpoint::Objects => {
202 +
                let refs = wire::decode_hashes(&body)?;
203 +
                let bundle = oxy_core::protocol::Bundle { objects: r.objects_for(&refs)?, ..Default::default() };
204 +
                ok(wire::encode_bundle(&bundle))
205 +
            }
206 +
            Endpoint::Checkpoint => {
207 +
                let heads = wire::decode_hashes(&body)?;
208 +
                let lock = self.lock_for(name);
209 +
                let _guard = lock.lock().unwrap();
210 +
                ok(wire::encode_bundle(&r.checkpoint_bundle_for(&heads)?))
211 +
            }
212 +
            Endpoint::Shallow => {
213 +
                let (heads, depth) = wire::decode_shallow(&body)?;
214 +
                ok(wire::encode_hashes(&r.shallow_changes(&heads, depth)?))
215 +
            }
216 +
            Endpoint::Push => {
217 +
                let (bundle, updates, policy, tags) = wire::decode_push(&body)?;
218 +
                let lock = self.lock_for(name);
219 +
                let _guard = lock.lock().unwrap();
220 +
                let results = r.receive_push(bundle, updates, policy, tags)?;
221 +
                self.backend.after_push(name, &results);
222 +
                ok(wire::encode_push_response(&results))
223 +
            }
224 +
        })
225 +
    }
226 +
}
227 +
228 +
/// The backend of `oxy serve`: one repository per subdirectory of a root,
229 +
/// open reads, and pushes gated by a list of bearer tokens.
230 +
pub struct DirBackend {
231 +
    pub root: PathBuf,
232 +
    pub tokens: Vec<String>,
233 +
}
234 +
235 +
impl Backend for DirBackend {
71 236
    fn open(&self, name: &str) -> Result<Repo, Failure> {
72 237
        if !valid_repo_name(name) {
73 238
            return Err(Failure(StatusCode::NOT_FOUND, "no such repository".into()));
74 239
        }
75 -
        let dir = self.cfg.root.join(name);
240 +
        let dir = self.root.join(name);
76 241
        if !dir.join(".oxy").is_dir() {
77 242
            return Err(Failure(StatusCode::NOT_FOUND, "no such repository".into()));
78 243
        }
79 244
        Repo::open(&dir).map_err(|e| Failure(StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))
80 245
    }
81 246
82 -
    fn lock_for(&self, name: &str) -> Arc<Mutex<()>> {
83 -
        self.locks.lock().unwrap().entry(name.to_string()).or_default().clone()
84 -
    }
85 -
86 -
    fn authorize_push(&self, headers: &HeaderMap) -> Result<(), Failure> {
87 -
        if self.cfg.tokens.is_empty() {
247 +
    fn authorize(&self, _name: &str, access: Access, headers: &HeaderMap) -> Result<(), Failure> {
248 +
        if access == Access::Read || self.tokens.is_empty() {
88 249
            return Ok(());
89 250
        }
90 -
        let presented = headers
91 -
            .get(header::AUTHORIZATION)
92 -
            .and_then(|v| v.to_str().ok())
93 -
            .and_then(|v| v.strip_prefix("Bearer "))
94 -
            .map(str::trim);
95 -
        match presented {
96 -
            Some(t) if self.cfg.tokens.iter().any(|x| x == t) => Ok(()),
251 +
        match bearer_token(headers) {
252 +
            Some(t) if self.tokens.iter().any(|x| x == t) => Ok(()),
97 253
            _ => Err(Failure(StatusCode::UNAUTHORIZED, "a valid bearer token is required to push".into())),
98 254
        }
99 255
    }
100 -
}
101 -
102 -
async fn blocking<T: Send + 'static>(f: impl FnOnce() -> Result<T, Failure> + Send + 'static) -> Result<T, Failure> {
103 -
    tokio::task::spawn_blocking(f).await.map_err(|e| Failure(StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?
104 256
}
105 257
106 -
async fn capabilities(State(app): State<Shared>, Path(repo): Path<String>) -> Result<Response, Failure> {
107 -
    blocking(move || {
108 -
        let r = app.open(&repo)?;
109 -
        Ok(ok(wire::encode_capabilities(r.id()?)))
110 -
    })
111 -
    .await
112 -
}
113 -
114 -
async fn refs(State(app): State<Shared>, Path(repo): Path<String>) -> Result<Response, Failure> {
115 -
    blocking(move || {
116 -
        let r = app.open(&repo)?;
117 -
        Ok(ok(wire::encode_refs(r.id()?, &r.remote_refs()?)))
118 -
    })
119 -
    .await
120 -
}
121 -
122 -
async fn negotiate(State(app): State<Shared>, Path(repo): Path<String>, body: Bytes) -> Result<Response, Failure> {
123 -
    blocking(move || {
124 -
        let r = app.open(&repo)?;
125 -
        let (have, want) = wire::decode_negotiate(&body)?;
126 -
        Ok(ok(wire::encode_hashes(&r.negotiate(&have, &want)?)))
127 -
    })
128 -
    .await
129 -
}
130 -
131 -
async fn fetch(State(app): State<Shared>, Path(repo): Path<String>, body: Bytes) -> Result<Response, Failure> {
132 -
    blocking(move || {
133 -
        let r = app.open(&repo)?;
134 -
        let (changes, max_blob) = wire::decode_fetch(&body)?;
135 -
        Ok(ok(wire::encode_bundle(&r.bundle_for_filtered(&changes, max_blob)?)))
136 -
    })
137 -
    .await
138 -
}
139 -
140 -
async fn checkpoint(State(app): State<Shared>, Path(repo): Path<String>, body: Bytes) -> Result<Response, Failure> {
141 -
    blocking(move || {
142 -
        let r = app.open(&repo)?;
143 -
        let heads = wire::decode_hashes(&body)?;
144 -
        let lock = app.lock_for(&repo);
145 -
        let _guard = lock.lock().unwrap();
146 -
        Ok(ok(wire::encode_bundle(&r.checkpoint_bundle_for(&heads)?)))
147 -
    })
148 -
    .await
149 -
}
150 -
151 -
async fn shallow(State(app): State<Shared>, Path(repo): Path<String>, body: Bytes) -> Result<Response, Failure> {
152 -
    blocking(move || {
153 -
        let r = app.open(&repo)?;
154 -
        let (heads, depth) = wire::decode_shallow(&body)?;
155 -
        Ok(ok(wire::encode_hashes(&r.shallow_changes(&heads, depth)?)))
156 -
    })
157 -
    .await
158 -
}
159 -
160 -
async fn objects(State(app): State<Shared>, Path(repo): Path<String>, body: Bytes) -> Result<Response, Failure> {
161 -
    blocking(move || {
162 -
        let r = app.open(&repo)?;
163 -
        let refs = wire::decode_hashes(&body)?;
164 -
        let bundle = oxy_core::protocol::Bundle { objects: r.objects_for(&refs)?, ..Default::default() };
165 -
        Ok(ok(wire::encode_bundle(&bundle)))
166 -
    })
167 -
    .await
258 +
/// The token of an `Authorization: Bearer` header, if any.
259 +
pub fn bearer_token(headers: &HeaderMap) -> Option<&str> {
260 +
    headers.get(header::AUTHORIZATION).and_then(|v| v.to_str().ok()).and_then(|v| v.strip_prefix("Bearer ")).map(str::trim)
168 261
}
169 262
170 -
async fn push(State(app): State<Shared>, Path(repo): Path<String>, headers: HeaderMap, body: Bytes) -> Result<Response, Failure> {
171 -
    app.authorize_push(&headers)?;
172 -
    blocking(move || {
173 -
        let r = app.open(&repo)?;
174 -
        let (bundle, updates, policy, tags) = wire::decode_push(&body)?;
175 -
        let lock = app.lock_for(&repo);
176 -
        let _guard = lock.lock().unwrap();
177 -
        Ok(ok(wire::encode_push_response(&r.receive_push(bundle, updates, policy, tags)?)))
178 -
    })
179 -
    .await
263 +
async fn dispatch(State(server): State<Arc<Server>>, Path((repo, endpoint)): Path<(String, String)>, method: Method, headers: HeaderMap, body: Bytes) -> Response {
264 +
    match Endpoint::parse(&endpoint) {
265 +
        Some(e) => server.handle(repo, e, &method, &headers, body).await,
266 +
        None => Failure(StatusCode::NOT_FOUND, "no such endpoint".into()).into_response(),
267 +
    }
180 268
}
181 269
270 +
/// The router of `oxy serve`: `/{repo}/oxy/v1/{endpoint}` over a [`DirBackend`].
182 271
pub fn router(cfg: ServerConfig) -> Router {
183 -
    let app: Shared = Arc::new(App { cfg, locks: Mutex::new(HashMap::new()) });
184 -
    Router::new()
185 -
        .route("/{repo}/oxy/v1/capabilities", get(capabilities))
186 -
        .route("/{repo}/oxy/v1/refs", get(refs))
187 -
        .route("/{repo}/oxy/v1/negotiate", post(negotiate))
188 -
        .route("/{repo}/oxy/v1/fetch", post(fetch))
189 -
        .route("/{repo}/oxy/v1/objects", post(objects))
190 -
        .route("/{repo}/oxy/v1/checkpoint", post(checkpoint))
191 -
        .route("/{repo}/oxy/v1/shallow", post(shallow))
192 -
        .route("/{repo}/oxy/v1/push", post(push))
193 -
        .with_state(app)
272 +
    let server = Server::new(DirBackend { root: cfg.root, tokens: cfg.tokens });
273 +
    Router::new().route("/{repo}/oxy/v1/{endpoint}", any(dispatch)).with_state(server)
194 274
}
195 275
196 276
/// Serve on an already-bound listener until the process ends; HTTPS when
…
23 unchanged lines
modified crates/oxy-server/tests/http.rs +130 −0
…
235 unchanged lines
236 236
    assert!(ops.len() > N);
237 237
}
238 238
239 +
240 +
#[test]
241 +
fn a_bare_repository_is_served_and_browsed_without_a_working_copy() {
242 +
    hermetic();
243 +
    let dir = tempfile::tempdir().unwrap();
244 +
    let root = dir.path();
245 +
    let served = root.join("served");
246 +
    let hosted = Repo::init_bare(&served.join("lib")).unwrap();
247 +
    assert!(hosted.is_bare());
248 +
    assert!(hosted.working_copy().is_err(), "a bare repository has no working copy");
249 +
    assert!(!served.join("lib").join(".oxy").join("workspace").exists());
250 +
    let base = start(&served, vec![]);
251 +
    let url = format!("{base}/lib");
252 +
253 +
    // A client clones the empty repository, commits twice, and pushes.
254 +
    let work = root.join("work");
255 +
    let client = Repo::clone(&url, &work).unwrap();
256 +
    write(&work, "src/lib.rs", b"pub fn one() {}\n");
257 +
    client.snapshot("test").unwrap();
258 +
    let c1 = client.commit("one", &[]).unwrap();
259 +
    write(&work, "src/lib.rs", b"pub fn one() {}\npub fn two() {}\n");
260 +
    client.snapshot("test").unwrap();
261 +
    let c2 = client.commit("two", &[]).unwrap();
262 +
    let results = client.push("origin", false).unwrap();
263 +
    assert!(results.iter().all(|r| r.ok), "{results:?}");
264 +
265 +
    // The host reads history, files and blame from the bookmark's state alone.
266 +
    let hosted = Repo::open(&served.join("lib")).unwrap();
267 +
    assert!(hosted.is_bare());
268 +
    let heads = hosted.bookmark_heads().unwrap();
269 +
    assert_eq!(heads, vec![c2]);
270 +
    let log = hosted.log().unwrap();
271 +
    assert_eq!(log.iter().map(|e| e.hash).collect::<Vec<_>>(), vec![c2, c1]);
272 +
    assert!(log.iter().all(|e| !e.is_working_copy));
273 +
    assert_eq!(hosted.log_state(&[c1]).unwrap().len(), 1);
274 +
    assert_eq!(hosted.file_at("src/lib.rs", Some(&heads)).unwrap(), b"pub fn one() {}\npub fn two() {}\n");
275 +
    let blame = hosted.blame_in("src/lib.rs", &heads).unwrap();
276 +
    assert_eq!(blame.iter().map(|l| l.hash).collect::<Vec<_>>(), vec![c1, c2]);
277 +
    assert_eq!(hosted.log_for_path("src/lib.rs").unwrap().len(), 2);
278 +
    assert_eq!(hosted.diff_files(Some(&c2)).unwrap().len(), 1);
279 +
    assert!(matches!(hosted.verify_change(&c2).unwrap(), SignatureStatus::Unsigned | SignatureStatus::Valid { .. }));
280 +
281 +
    // Snapshotting a bare repository is refused rather than inventing a working copy.
282 +
    assert!(hosted.snapshot("test").is_err());
283 +
}
284 +
285 +
/// A host embeds the protocol with its own backend: names with an owner
286 +
/// segment, reads gated per repository, and a push hook.
287 +
#[test]
288 +
fn a_custom_backend_embeds_the_protocol() {
289 +
    use axum::extract::{Path as AxPath, State};
290 +
    use axum::http::{HeaderMap, Method, StatusCode};
291 +
    use axum::response::{IntoResponse, Response};
292 +
    use oxy_server::{Access, Backend, Endpoint, Failure, Server};
293 +
    use std::sync::{Arc, Mutex};
294 +
295 +
    struct Hub {
296 +
        root: std::path::PathBuf,
297 +
        pushes: Arc<Mutex<Vec<String>>>,
298 +
    }
299 +
    impl Backend for Hub {
300 +
        fn open(&self, name: &str) -> Result<Repo, Failure> {
301 +
            let Some((owner, repo)) = name.split_once('/') else { return Err(Failure(StatusCode::NOT_FOUND, "no such repository".into())) };
302 +
            let dir = self.root.join(owner).join(repo);
303 +
            if !dir.join(".oxy").is_dir() {
304 +
                return Err(Failure(StatusCode::NOT_FOUND, "no such repository".into()));
305 +
            }
306 +
            Repo::open(&dir).map_err(|e| Failure(StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))
307 +
        }
308 +
        fn authorize(&self, name: &str, access: Access, headers: &HeaderMap) -> Result<(), Failure> {
309 +
            // `secret/*` is private: unknown to anyone without the token, even for reads.
310 +
            let token = oxy_server::bearer_token(headers);
311 +
            if name.starts_with("secret/") && token != Some("s3cret") {
312 +
                return Err(Failure(StatusCode::NOT_FOUND, "no such repository".into()));
313 +
            }
314 +
            if access == Access::Push && token.is_none() {
315 +
                return Err(Failure(StatusCode::UNAUTHORIZED, "sign in to push".into()));
316 +
            }
317 +
            Ok(())
318 +
        }
319 +
        fn after_push(&self, name: &str, results: &[oxy_core::protocol::RefResult]) {
320 +
            self.pushes.lock().unwrap().push(format!("{name}: {}", results.iter().filter(|r| r.ok).count()));
321 +
        }
322 +
    }
323 +
    async fn route(State(server): State<Arc<Server>>, AxPath((owner, repo, endpoint)): AxPath<(String, String, String)>, method: Method, headers: HeaderMap, body: axum::body::Bytes) -> Response {
324 +
        match Endpoint::parse(&endpoint) {
325 +
            Some(e) => server.handle(format!("{owner}/{repo}"), e, &method, &headers, body).await,
326 +
            None => Failure(StatusCode::NOT_FOUND, "no such endpoint".into()).into_response(),
327 +
        }
328 +
    }
329 +
330 +
    hermetic();
331 +
    let dir = tempfile::tempdir().unwrap();
332 +
    let root = dir.path();
333 +
    Repo::init_bare(&root.join("repos/andy/app")).unwrap();
334 +
    Repo::init_bare(&root.join("repos/secret/plans")).unwrap();
335 +
    let pushes = Arc::new(Mutex::new(Vec::new()));
336 +
    let server = Server::new(Hub { root: root.join("repos"), pushes: pushes.clone() });
337 +
    let listener = TcpListener::bind("127.0.0.1:0").unwrap();
338 +
    let port = listener.local_addr().unwrap().port();
339 +
    std::thread::spawn(move || {
340 +
        let rt = tokio::runtime::Builder::new_multi_thread().enable_all().build().unwrap();
341 +
        rt.block_on(async move {
342 +
            listener.set_nonblocking(true).unwrap();
343 +
            let app = axum::Router::new().route("/{owner}/{repo}/oxy/v1/{endpoint}", axum::routing::any(route)).with_state(server);
344 +
            axum::serve(tokio::net::TcpListener::from_std(listener).unwrap(), app).await.unwrap();
345 +
        });
346 +
    });
347 +
    std::thread::sleep(std::time::Duration::from_millis(50));
348 +
    let base = format!("http://127.0.0.1:{port}");
349 +
350 +
    // Anonymous clone of the public repository works; the private one does not exist for outsiders.
351 +
    let work = root.join("work");
352 +
    let client = Repo::clone(&format!("{base}/andy/app"), &work).unwrap();
353 +
    assert!(Repo::clone(&format!("{base}/secret/plans"), &root.join("nope")).is_err());
354 +
355 +
    // A push needs a token; with one it lands and the hook sees it.
356 +
    write(&work, "a.txt", b"a\n");
357 +
    client.snapshot("test").unwrap();
358 +
    client.commit("a", &[]).unwrap();
359 +
    assert!(client.push("origin", false).is_err(), "anonymous push must be refused");
360 +
    let mut client = client;
361 +
    client.config.set("remote.origin.token", "s3cret").unwrap();
362 +
    let results = client.push("origin", false).unwrap();
363 +
    assert!(results.iter().all(|r| r.ok), "{results:?}");
364 +
    assert_eq!(pushes.lock().unwrap().as_slice(), ["andy/app: 1"]);
365 +
366 +
    let hosted = Repo::open(&root.join("repos/andy/app")).unwrap();
367 +
    assert_eq!(hosted.bookmark_heads().unwrap().len(), 1);
368 +
}
modified docs/plan.md +11 −0
…
77 unchanged lines
78 78
- 2026-09-27 01:05 iteration 3: sparse checkout. 94 tests.
79 79
- 2026-09-27 00:45 iteration 2: lazy blobs and objects endpoint. 93 tests.
80 80
- 2026-09-27 00:05 iteration 1: checkpoints, pack merging, CLI presentation, shell prompt. 91 tests.
81 +
82 +
## Phase 4 (2026-09-27): what Oxygenhub needs (oxygenhub/docs/spec.md section 18)
83 +
84 +
- [x] Bare repositories: `Repo::init_bare`, `is_bare`, `oxy init --bare`; `working_copy` refuses; `log`, `log_for_path` work from the bookmark state; `log_state`, `blame_in`, `log_for_path_in` for any state.
85 +
- [x] Embeddable server: `oxy_server::{Backend, Server, Endpoint, Access, Failure, DirBackend, bearer_token}`; `router` is `DirBackend` on `/{repo}/oxy/v1/{endpoint}`; `after_push` hook.
86 +
- [ ] `oxy identity sign-challenge` and `verify_challenge` (hub 0.2).
87 +
- [ ] `oxy seal <hash>..` and `--proposal URL` (hub 0.3).
88 +
- [ ] `oxy proposal show|land URL` (hub 0.3).
89 +
- [ ] Delegate-signed changes from an edit set without a workspace (hub 0.6).
90 +
- [ ] `Repo::log_window` and a shared `graph::layout` (hub 0.1 uses `log_state` and its own layout until then).
91 +
- [ ] `receive_push` reporting old/new state and new changes per reference (hub 0.2 pushes log).
modified docs/protocol.md +6 −0
…
57 unchanged lines
58 58
  self-signed certificates are expected to hand that file out.
59 59
- Pushes to one repository are serialised by the server; reads are not.
60 60
61 +
Embedding: `oxy_server::Server` answers any endpoint for a repository name
62 +
a host's `Backend` resolves and authorises (`open`, `authorize`, and an
63 +
`after_push` hook), so a hub with `owner/name` paths and its own accounts
64 +
routes `/{owner}/{name}/oxy/v1/{endpoint}` to `Server::handle`; `oxy serve`
65 +
is `DirBackend` on that same server.
66 +
61 67
The spec (§16) also defines framing for Noise and ZCT transports (`u32
62 68
big-endian length || bytes`); the reference implementation speaks HTTP only.
63 69
…
125 unchanged lines
modified docs/spec.md +4 −4
…
1 unchanged lines
2 2
3 3
| | |
4 4
|---|---|
5 -
| **Status** | Draft 0.8b |
5 +
| **Status** | Draft 0.8c |
6 6
| **Format version** | 1 |
7 7
| **Date** | 2026-09-26 |
8 8
| **Audience** | Implementers, security reviewers, and contributors |
…
466 unchanged lines
475 475
476 476
### 12.2 Workspaces
477 477
478 -
A repository has one or more workspaces, each with a `WorkspaceId` (128-bit random) and a checkout directory. Each workspace has exactly one **working-copy change** at any time. The primary workspace is the directory holding `.oxy/`; a secondary workspace holds a `.oxy` file instead, `oxydir: <path>` and `workspace: <id>`, pointing at the shared repository, and the repository records each workspace's directory under `.oxy/workspaces/<id>/`. `oxy workspace add PATH [CHANGE..]` creates one, `list` shows them, and `forget` drops one from the view without touching its files. Workspaces do not nest.
478 +
A repository has one or more workspaces, each with a `WorkspaceId` (128-bit random) and a checkout directory. Each workspace has exactly one **working-copy change** at any time. The primary workspace is the directory holding `.oxy/`; a secondary workspace holds a `.oxy` file instead, `oxydir: <path>` and `workspace: <id>`, pointing at the shared repository, and the repository records each workspace's directory under `.oxy/workspaces/<id>/`. `oxy workspace add PATH [CHANGE..]` creates one, `list` shows them, and `forget` drops one from the view without touching its files. Workspaces do not nest. A **bare** repository (`oxy init --bare`, marker `.oxy/bare`) has no workspace and no working-copy change; it holds objects, the operation log and references only, for hosting. Working-copy commands fail on it with a clear message; every state-based query works.
479 479
480 480
### 12.3 The working-copy change
481 481
…
333 unchanged lines
815 815
Commands mirror Jujutsu where semantics match, so that muscle memory transfers.
816 816
817 817
```
818 -
oxy init [PATH]                     oxy clone URL [--sparse PATH].. [--depth N] [--max-blob SIZE]
818 +
oxy init [PATH] [--bare]            oxy clone URL [--sparse PATH].. [--depth N] [--max-blob SIZE]
819 819
oxy status [--json]                 oxy diff                     oxy prompt [--format F]
820 820
oxy commit -m MSG [--depends-on CHANGE]..
821 821
oxy log [-n N] [-v]                 oxy show CHANGE
…
93 unchanged lines
915 915
916 916
---
917 917
918 -
*Changelog.* Draft 0.8b (2026-09-27): the machine interface for editors (§24): `root`, `cat`, `version`, `hunks`, `discard`, and `--json` on the reporting commands.
918 +
*Changelog.* Draft 0.8c (2026-09-27): bare repositories (`oxy init --bare`, §12.2: no workspace, no working copy; hosting) and the reference server as an embeddable library (§15.1). Draft 0.8b (2026-09-27): the machine interface for editors (§24): `root`, `cat`, `version`, `hunks`, `discard`, and `--json` on the reporting commands.
919 919
920 920
Draft 0.8a (2026-09-27): every Draft 0.8 addition is implemented in the reference implementation (§26.1). Clarifications from doing so: an unregistered nested repository is left alone and a tracked subrepo whose directory is absent keeps its pin (§7.8); Oxygen pins are omitted from Git export (§23).
921 921
…
59 unchanged lines
modified src/cli/mod.rs +7 −2
…
24 unchanged lines
25 25
#[derive(Subcommand)]
26 26
enum Cmd {
27 27
    /// Create a repository in PATH (default: current directory)
28 -
    Init { path: Option<PathBuf> },
28 +
    Init {
29 +
        path: Option<PathBuf>,
30 +
        /// No working copy: an object store and references only, for hosting
31 +
        #[arg(long)]
32 +
        bare: bool,
33 +
    },
29 34
    /// Clone a repository from a local path or an http(s) URL
30 35
    Clone {
31 36
        url: String,
…
546 unchanged lines
578 583
    let cli = Cli::parse();
579 584
    ui::init(cli.plain);
580 585
    match cli.cmd {
581 -
        Cmd::Init { path } => workspace::init(path),
586 +
        Cmd::Init { path, bare } => workspace::init(path, bare),
582 587
        Cmd::Status { json } => workspace::status(json),
583 588
        Cmd::Prompt { format, glyphs, color } => workspace::prompt(&format, glyphs, color.as_deref()),
584 589
        Cmd::Commit { message, depends_on, paths, interactive, hunks } => workspace::commit(&message, &depends_on, &paths, interactive, &hunks),
…
100 unchanged lines
modified src/cli/workspace.rs +3 −3
…
9 unchanged lines
10 10
use oxy_core::repo::{render_change, ChangeStatus, Repo};
11 11
use std::path::PathBuf;
12 12
13 -
pub fn init(path: Option<PathBuf>) -> Result<i32> {
13 +
pub fn init(path: Option<PathBuf>, bare: bool) -> Result<i32> {
14 14
    let path = path.unwrap_or(std::env::current_dir()?);
15 -
    let repo = Repo::init(&path)?;
16 -
    println!("{} oxy repository in {}", ui::done("Initialized"), ui::path(repo.root().display()));
15 +
    let repo = if bare { Repo::init_bare(&path)? } else { Repo::init(&path)? };
16 +
    println!("{} {}oxy repository in {}", ui::done("Initialized"), if bare { "bare " } else { "" }, ui::path(repo.root().display()));
17 17
    println!("{}", ui::kv("bookmark", ui::bookmark(&repo.config.bookmark)));
18 18
    println!("{}", ui::kv("author", format!("{} <{}>", ui::author(&repo.config.author_name), repo.config.author_email)));
19 19
    Ok(0)
…
952 unchanged lines