OXYGENHUB

Git bridge: oxy git fetch and oxy git push against a Git remote through a bare mirror, incremental import seeded by imported and exported commits, export chained onto fetched tips (spec 0.6p) 30ea9c1e70fa

Author
Andy McCall @andymccall <[email protected]>
When
· 2026-09-26 23:47 UTC
Signature
✔ signed by Andy McCall
Hash
30ea9c1e70faf8a3bcdcd8b919147347185a389e36ce266ed07e53a984f25185
Depends on
132b884b9a8d 135212de5370 28fc23d7706a 648724d8e0de 6e723b81f5b9 79ab8528ff62 83e0677b07fe 96f7610fa666 982864068a59 c3069537d7c5 d02e545dc265 fce44ae9ad29 ff0af1ee299d
Depended on by
c397a8c36a9f d6e0d5b8b339 810691ca6ce6 54e2e6309aa3 9935d490ddfb 58dfe0e134eb fd280228bdc2 fb5b6181618a 2306a4d24779 b493d993a9df dfb23a3a8cfa 958fb27dda7c 03571027793e 7891e21c762f 62794ecc7f74 d42369abf180 05e71ed6cdf1 75167672ebcd 728da0f92161 4dee277c327a 86fe29fd62ea 50a046e439e9 8f10a07a1dbe 2b872d074ddb
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
47 atoms, 0 tree operations, 7 files

Browse the tree at this change

modified README.md +4 −0
…
60 unchanged lines
61 61
oxy push                                 # publishes the policy
62 62
```
63 63
64 +
Working alongside Git: `oxy git import <repo> <dest>` converts a Git history, `oxy git fetch <url>`
65 +
brings new commits of a Git remote into the bookmark, `oxy git push <url>` publishes the bookmark as
66 +
commits on top of them, and `oxy git export <dir>` writes a plain Git mirror.
67 +
64 68
Keys have a lifecycle: `oxy identity rotate` moves to a fresh key pair (the policy follows),
65 69
`oxy identity renew --expires 365` sets an expiry, `oxy identity delegate add bot.identity`
66 70
lets another key sign for you, and `oxy identity revoke` retires a key. With several root
…
15 unchanged lines
modified crates/oxy-core/src/repo/git.rs +230 −37
…
138 unchanged lines
139 139
    ChangeId(h.as_bytes()[..16].try_into().unwrap())
140 140
}
141 141
142 +
/// Git commits with a known Oxygen counterpart.
143 +
struct GitKnown {
144 +
    /// Imported commit → its change.
145 +
    imported: HashMap<String, ChangeHash>,
146 +
    /// Exported commit (or a fetched tip) → the bookmark state it equals.
147 +
    exported: HashMap<String, ObjectRef>,
148 +
}
149 +
142 150
#[derive(Debug, Clone, Default, PartialEq, Eq)]
143 151
pub struct GitExportReport {
144 152
    pub exported: usize,
…
19 unchanged lines
164 172
        Ok(dir)
165 173
    }
166 174
175 +
    /// `state ref → Git commit sha` for bookmark states that equal a Git
176 +
    /// commit's tree (recorded by import and fetch).
177 +
    fn read_git_states(path: &Path) -> Result<BTreeMap<ObjectRef, String>> {
178 +
        let mut out = BTreeMap::new();
179 +
        if let Ok(text) = fs::read_to_string(path) {
180 +
            for line in text.lines() {
181 +
                if let Some((h, sha)) = line.split_once(' ') {
182 +
                    if let Some(h) = ObjectRef::from_hex(h) {
183 +
                        out.insert(h, sha.trim().to_string());
184 +
                    }
185 +
                }
186 +
            }
187 +
        }
188 +
        Ok(out)
189 +
    }
190 +
191 +
    fn record_git_state(&self, git_dir: &Path, state: &ObjectRef, sha: &str) -> Result<()> {
192 +
        let path = self.git_marks_dir(git_dir)?.join("git-states");
193 +
        let mut states = Self::read_git_states(&path)?;
194 +
        states.insert(*state, sha.to_string());
195 +
        let text: String = states.iter().map(|(h, s)| format!("{} {s}\n", h.to_hex())).collect();
196 +
        write_atomic(&path, text.as_bytes())
197 +
    }
198 +
199 +
    /// The bare mirror this repository keeps for the Git remote `url`.
200 +
    fn git_mirror(&self, url: &str) -> Result<PathBuf> {
201 +
        let key = hash_object(ObjectType::Chunk, url.as_bytes()).short(16);
202 +
        let dir = self.oxy.join("git").join(key).join("mirror.git");
203 +
        if !dir.join("HEAD").exists() {
204 +
            fs::create_dir_all(&dir)?;
205 +
            git(&dir, &["init", "--bare", "-q"])?;
206 +
        }
207 +
        Ok(dir)
208 +
    }
209 +
210 +
    /// Git commits already imported into the bookmark, by sha.
211 +
    fn imported_commits(&self) -> Result<HashMap<String, ChangeHash>> {
212 +
        let mut map = HashMap::new();
213 +
        for e in self.log()? {
214 +
            if e.is_working_copy {
215 +
                continue;
216 +
            }
217 +
            if let Some(sha) = e.change.message.lines().rev().find_map(|l| l.strip_prefix("Git-Commit: ")) {
218 +
                map.insert(sha.trim().to_string(), e.hash);
219 +
            }
220 +
        }
221 +
        Ok(map)
222 +
    }
223 +
224 +
    /// Bring the mirror's `branch` up to date with the Git remote `url`.
225 +
    /// A remote without that branch yet leaves the mirror as it is.
226 +
    fn git_mirror_fetch(&self, mirror: &Path, url: &str, branch: &str) -> Result<()> {
227 +
        let spec = format!("+refs/heads/{branch}:refs/heads/{branch}");
228 +
        match git(mirror, &["fetch", "-q", url, &spec]) {
229 +
            Ok(_) => Ok(()),
230 +
            Err(e) => {
231 +
                let ls = git(mirror, &["ls-remote", "--heads", url, branch]).unwrap_or_default();
232 +
                if ls.trim().is_empty() { Ok(()) } else { Err(e) }
233 +
            }
234 +
        }
235 +
    }
236 +
237 +
    /// Fetch `branch` of the Git repository at `url` (a path or a Git URL)
238 +
    /// through a mirror and import the commits not yet in the bookmark
239 +
    /// (spec §23). Works on a repository that already has history: the
240 +
    /// new changes join the bookmark like a pull.
241 +
    pub fn git_fetch(&self, url: &str, branch: &str) -> Result<GitImportReport> {
242 +
        let mirror = self.git_mirror(url)?;
243 +
        self.git_mirror_fetch(&mirror, url, branch)?;
244 +
        if git(&mirror, &["rev-parse", "--verify", "-q", &format!("refs/heads/{branch}")]).is_err() {
245 +
            return fail(format!("{url} has no branch {branch:?}"));
246 +
        }
247 +
        self.import_git_commits(&mirror, branch, &format!("oxy git fetch {url} {branch}"))
248 +
    }
249 +
250 +
    /// Export the bookmark to `branch` of the Git repository at `url`
251 +
    /// through the mirror and push it. The remote's tip must already have
252 +
    /// been fetched (`git_fetch`), so the push fast-forwards.
253 +
    pub fn git_push(&self, url: &str, branch: &str) -> Result<GitExportReport> {
254 +
        let mirror = self.git_mirror(url)?;
255 +
        self.git_mirror_fetch(&mirror, url, branch)?;
256 +
        if let Ok(tip) = git(&mirror, &["rev-parse", "--verify", "-q", &format!("refs/heads/{branch}")]) {
257 +
            let known = self.git_known(&mirror)?;
258 +
            if !known.imported.contains_key(&tip) && !known.exported.contains_key(&tip) {
259 +
                return fail(format!("{url} {branch} has commits not yet fetched; run `oxy git fetch {url}` first"));
260 +
            }
261 +
        }
262 +
        let report = self.git_export_as(&mirror, branch)?;
263 +
        let spec = format!("refs/heads/{branch}:refs/heads/{branch}");
264 +
        git(&mirror, &["push", "-q", url, &spec])?;
265 +
        Ok(report)
266 +
    }
267 +
167 268
    fn read_oxy_marks(path: &Path) -> Result<BTreeMap<ObjectRef, u64>> {
168 269
        let mut out = BTreeMap::new();
169 270
        if let Ok(text) = fs::read_to_string(path) {
…
13 unchanged lines
183 284
    /// passed through (per the operation log) becomes one commit whose tree
184 285
    /// is that state, so the Git branch is a linear history of full trees.
185 286
    pub fn git_export(&self, git_dir: &Path) -> Result<GitExportReport> {
287 +
        let bookmark = self.config.bookmark.clone();
288 +
        self.git_export_as(git_dir, &bookmark)
289 +
    }
290 +
291 +
    /// `git_export` to the Git branch `branch`. A state that came from a
292 +
    /// Git commit (`oxy git fetch`) is not re-exported: later states chain
293 +
    /// onto that commit, so a push back to the Git remote fast-forwards.
294 +
    pub fn git_export_as(&self, git_dir: &Path, branch: &str) -> Result<GitExportReport> {
186 295
        if !git_dir.join("HEAD").exists() && !git_dir.join(".git").exists() {
187 296
            fs::create_dir_all(git_dir)?;
188 297
            git(git_dir, &["init", "--bare", "-q"])?;
…
2 unchanged lines
191 300
        let git_marks = marks_dir.join("git-marks");
192 301
        let oxy_marks_path = marks_dir.join("oxy-marks");
193 302
        let mut marks = Self::read_oxy_marks(&oxy_marks_path)?;
303 +
        let git_states = Self::read_git_states(&marks_dir.join("git-states"))?;
194 304
        let mut next_mark = marks.values().copied().max().unwrap_or(0) + 1;
195 305
196 306
        let bookmark = self.config.bookmark.clone();
197 307
        let mut stream: Vec<u8> = Vec::new();
198 -
        let mut report = GitExportReport { branch: bookmark.clone(), heads: self.bookmark_heads()?.len(), ..Default::default() };
308 +
        let mut report = GitExportReport { branch: branch.to_string(), heads: self.bookmark_heads()?.len(), ..Default::default() };
199 309
200 310
        // States along the bookmark, oldest first, skipping unchanged and pruned ones.
201 311
        let mut ops = self.op_log()?;
202 312
        ops.reverse();
203 313
        let mut prev: Option<(ObjectRef, Vec<ChangeHash>)> = None;
204 -
        let mut last_mark: Option<u64> = None;
314 +
        // The fast-import parent of the next commit: `:mark` or a commit sha.
315 +
        let mut last_parent: Option<String> = None;
205 316
        for (_, op) in &ops {
206 317
            let Some(state_ref) = op.view.bookmarks.get(&bookmark).copied() else { continue };
207 318
            if prev.as_ref().is_some_and(|(p, _)| *p == state_ref) {
…
4 unchanged lines
212 323
                prev = Some((state_ref, vec![]));
213 324
                continue;
214 325
            }
326 +
            if let Some(sha) = git_states.get(&state_ref) {
327 +
                report.skipped += 1;
328 +
                last_parent = Some(sha.clone());
329 +
                prev = Some((state_ref, state.heads.clone()));
330 +
                continue;
331 +
            }
215 332
            if let Some(m) = marks.get(&state_ref) {
216 333
                report.skipped += 1;
217 -
                last_mark = Some(*m);
334 +
                last_parent = Some(format!(":{m}"));
218 335
                prev = Some((state_ref, state.heads.clone()));
219 336
                continue;
220 337
            }
…
28 unchanged lines
249 366
            }
250 367
            let mark = next_mark;
251 368
            next_mark += 1;
252 -
            let parents: Vec<u64> = last_mark.into_iter().collect();
369 +
            let parents: Vec<String> = last_parent.clone().into_iter().collect();
253 370
            self.write_commit(&mut stream, "refs/oxy/export", mark, &author, secs, &message, &parents, &snapshot)?;
254 371
            marks.insert(state_ref, mark);
255 -
            last_mark = Some(mark);
372 +
            last_parent = Some(format!(":{mark}"));
256 373
            prev = Some((state_ref, state.heads.clone()));
257 374
            report.exported += 1;
258 375
        }
259 -
        if let Some(m) = last_mark {
260 -
            stream.extend_from_slice(format!("reset refs/heads/{bookmark}\nfrom :{m}\n\n").as_bytes());
376 +
        if let Some(p) = &last_parent {
377 +
            stream.extend_from_slice(format!("reset refs/heads/{branch}\nfrom {p}\n\n").as_bytes());
261 378
        }
262 379
        // Tags: a tag whose state was exported as a bookmark state points at
263 380
        // that commit; otherwise its state gets a commit of its own.
264 381
        for t in self.tags()? {
265 -
            let mark = match marks.get(&t.state) {
266 -
                Some(m) => *m,
267 -
                None => {
382 +
            let target = match (git_states.get(&t.state), marks.get(&t.state)) {
383 +
                (Some(sha), _) => sha.clone(),
384 +
                (None, Some(m)) => format!(":{m}"),
385 +
                (None, None) => {
268 386
                    let loaded = self.load_state(&t.heads)?;
269 387
                    let snapshot = loaded.materialize();
270 388
                    let mark = next_mark;
…
2 unchanged lines
273 391
                    self.write_commit(&mut stream, "refs/oxy/export", mark, "oxy <oxy@localhost>", Hlc::now().wall_ms / 1000, &message, &[], &snapshot)?;
274 392
                    marks.insert(t.state, mark);
275 393
                    report.exported += 1;
276 -
                    mark
394 +
                    format!(":{mark}")
277 395
                }
278 396
            };
279 -
            stream.extend_from_slice(format!("reset refs/tags/{}\nfrom :{mark}\n\n", t.name).as_bytes());
397 +
            stream.extend_from_slice(format!("reset refs/tags/{}\nfrom {target}\n\n", t.name).as_bytes());
280 398
        }
281 399
        stream.extend_from_slice(b"done\n");
282 400
…
23 unchanged lines
306 424
        if !self.bookmark_heads()?.is_empty() {
307 425
            return fail("git import needs an empty bookmark; import into a fresh repository");
308 426
        }
427 +
        self.import_git_commits(git_dir, branch, &format!("oxy git import {} {branch}", git_dir.display()))
428 +
    }
429 +
430 +
    /// Import the commits of `branch` that the bookmark does not have yet,
431 +
    /// then move the bookmark to the union of its heads and theirs.
432 +
    fn import_git_commits(&self, git_dir: &Path, branch: &str, command: &str) -> Result<GitImportReport> {
309 433
        let commits = list_commits(git_dir, branch)?;
310 434
        let mut cat = CatFile::start(git_dir)?;
311 435
        let mut report = GitImportReport::default();
312 -
        let mut change_of: HashMap<String, ChangeHash> = HashMap::new();
436 +
        let mut known = self.git_known(git_dir)?;
437 +
        let old_heads = self.bookmark_heads()?;
438 +
        let mut new_changes: Vec<Change> = Vec::new();
313 439
        // The state after the most recently imported commit, reused when the
314 440
        // next commit's first parent is that commit.
315 441
        let mut last: Option<(String, State)> = None;
…
1 unchanged lines
317 443
        let mut cache_bytes: usize = 0;
318 444
319 445
        for gc in &commits {
446 +
            if known.imported.contains_key(&gc.sha) || known.exported.contains_key(&gc.sha) {
447 +
                continue;
448 +
            }
320 449
            // Base: union of every parent's state.
321 450
            let mut base = match (&gc.parents.first(), &last) {
322 451
                (Some(p1), Some((sha, st))) if *p1 == sha => st.clone(),
323 -
                (Some(p1), _) => self.git_ancestor_state(git_dir, &[p1.as_str()], &change_of)?,
452 +
                (Some(p1), _) => {
453 +
                    let mut st = State::new();
454 +
                    self.add_git_ancestors(&mut st, git_dir, &[p1.as_str()], &known)?;
455 +
                    st
456 +
                }
324 457
                (None, _) => State::new(),
325 458
            };
326 459
            if gc.parents.len() > 1 {
327 460
                report.merges += 1;
328 461
                let mut args: Vec<String> = gc.parents[1..].to_vec();
329 462
                args.push(format!("^{}", gc.parents[0]));
330 -
                let extra = git(git_dir, &["rev-list".to_string()].iter().map(String::as_str).chain(args.iter().map(String::as_str)).collect::<Vec<_>>())?;
331 -
                for sha in extra.lines() {
332 -
                    if let Some(h) = change_of.get(sha) {
333 -
                        base.add_unchecked(self.store.get_change(h)?);
334 -
                    }
335 -
                }
463 +
                let refs: Vec<&str> = args.iter().map(String::as_str).collect();
464 +
                self.add_git_ancestors(&mut base, git_dir, &refs, &known)?;
336 465
            }
337 466
            let base_snapshot = base.materialize();
338 467
…
47 unchanged lines
386 515
            change.deps = deps.into_iter().collect();
387 516
            change.normalize();
388 517
            let hash = self.store.put_change(&change)?;
518 +
            new_changes.push(change.clone());
389 519
            base.add_unchecked(change);
390 -
            change_of.insert(gc.sha.clone(), hash);
520 +
            known.imported.insert(gc.sha.clone(), hash);
391 521
            last = Some((gc.sha.clone(), base));
392 522
            report.commits += 1;
393 523
        }
394 524
395 -
        // The bookmark: heads of the tip's state; the working copy follows.
396 -
        let Some((_, tip_state)) = last else { return Ok(report) };
397 -
        let heads = tip_state.heads();
525 +
        // The bookmark: heads of the old state plus every new change.
526 +
        report.heads = old_heads.len();
527 +
        if last.is_none() {
528 +
            return Ok(report);
529 +
        }
530 +
        let mut all = self.load_state(&old_heads)?;
531 +
        for c in new_changes {
532 +
            all.add_unchecked(c);
533 +
        }
534 +
        let mut heads = all.heads();
535 +
        heads.sort();
398 536
        report.heads = heads.len();
399 537
        let (op_ref, op) = self.head_op()?;
400 538
        let mut view = op.view.clone();
401 -
        view.bookmarks.insert(self.config.bookmark.clone(), self.store.put_state(&StateObject::new(heads.clone()))?);
402 -
        self.record_op(&[op_ref], &format!("oxy git import {} {branch}", git_dir.display()), view)?;
403 -
        self.checkout(&heads, true, "oxy git import (checkout)")?;
539 +
        let old_state_ref = view.bookmarks.get(&self.config.bookmark).copied();
540 +
        let state_ref = self.store.put_state(&StateObject::new(heads.clone()))?;
541 +
        view.bookmarks.insert(self.config.bookmark.clone(), state_ref);
542 +
        self.record_op(&[op_ref], command, view)?;
543 +
        // The new state equals the tip's tree when the old one was itself a
544 +
        // Git commit's tree (fetched or exported), or there was none.
545 +
        let old_known = old_heads.is_empty() || old_state_ref.is_some_and(|s| known.exported.values().any(|e| *e == s));
546 +
        if let (Some(tip), true) = (commits.last(), old_known) {
547 +
            self.record_git_state(git_dir, &state_ref, &tip.sha)?;
548 +
        }
549 +
        // The working copy follows when it was clean on the old heads.
550 +
        if old_heads.is_empty() {
551 +
            self.checkout(&heads, true, &format!("{command} (checkout)"))?;
552 +
        } else {
553 +
            let snap = self.snapshot(command)?;
554 +
            let clean = snap.wc.tree_ops.is_empty() && snap.wc.edits.is_empty();
555 +
            let mut base = snap.base.maximal(&snap.wc.deps);
556 +
            base.sort();
557 +
            let mut sorted_old = old_heads.clone();
558 +
            sorted_old.sort();
559 +
            if clean && base == sorted_old {
560 +
                self.checkout(&heads, false, &format!("{command} (checkout)"))?;
561 +
            }
562 +
        }
404 563
        Ok(report)
405 564
    }
406 565
407 -
    /// The state holding the changes of every Git ancestor of `shas`.
408 -
    fn git_ancestor_state(&self, git_dir: &Path, shas: &[&str], change_of: &HashMap<String, ChangeHash>) -> Result<State> {
409 -
        let mut args = vec!["rev-list"];
410 -
        args.extend(shas);
411 -
        let out = git(git_dir, &args)?;
412 -
        let mut st = State::new();
566 +
    /// Add to `st` the changes of every Git commit `rev-list args` lists:
567 +
    /// an imported commit's change, or the whole state an exported commit
568 +
    /// was made from (which may hold changes Git never saw).
569 +
    fn add_git_ancestors(&self, st: &mut State, git_dir: &Path, args: &[&str], known: &GitKnown) -> Result<()> {
570 +
        let mut full = vec!["rev-list"];
571 +
        full.extend(args);
572 +
        let out = git(git_dir, &full)?;
413 573
        for sha in out.lines() {
414 -
            if let Some(h) = change_of.get(sha) {
574 +
            if let Some(h) = known.imported.get(sha) {
415 575
                st.add_unchecked(self.store.get_change(h)?);
576 +
            } else if let Some(state_ref) = known.exported.get(sha) {
577 +
                let heads = self.store.get_state(state_ref)?.heads;
578 +
                for h in self.negotiate(&[], &heads)? {
579 +
                    st.add_unchecked(self.store.get_change(&h)?);
580 +
                }
581 +
            }
582 +
        }
583 +
        Ok(())
584 +
    }
585 +
586 +
    /// Commits this repository can relate to Oxygen history: imported
587 +
    /// ones by change, exported ones (through `git_dir`'s marks) by state.
588 +
    fn git_known(&self, git_dir: &Path) -> Result<GitKnown> {
589 +
        let imported = self.imported_commits()?;
590 +
        let marks_dir = self.git_marks_dir(git_dir)?;
591 +
        let oxy_marks = Self::read_oxy_marks(&marks_dir.join("oxy-marks"))?;
592 +
        let mut sha_of_mark: HashMap<u64, String> = HashMap::new();
593 +
        if let Ok(text) = fs::read_to_string(marks_dir.join("git-marks")) {
594 +
            for line in text.lines() {
595 +
                if let Some((m, sha)) = line.split_once(' ') {
596 +
                    if let Ok(m) = m.trim_start_matches(':').parse::<u64>() {
597 +
                        sha_of_mark.insert(m, sha.trim().to_string());
598 +
                    }
599 +
                }
600 +
            }
601 +
        }
602 +
        let mut exported = HashMap::new();
603 +
        for (state_ref, m) in oxy_marks {
604 +
            if let Some(sha) = sha_of_mark.get(&m) {
605 +
                exported.insert(sha.clone(), state_ref);
416 606
            }
417 607
        }
418 -
        Ok(st)
608 +
        for (state_ref, sha) in Self::read_git_states(&marks_dir.join("git-states"))? {
609 +
            exported.entry(sha).or_insert(state_ref);
610 +
        }
611 +
        Ok(GitKnown { imported, exported })
419 612
    }
420 613
421 614
    fn git_author(&self, c: &Change) -> String {
…
4 unchanged lines
426 619
    }
427 620
428 621
    #[allow(clippy::too_many_arguments)]
429 -
    fn write_commit(&self, out: &mut Vec<u8>, reference: &str, mark: u64, author: &str, secs: u64, message: &str, parents: &[u64], snapshot: &Snapshot) -> Result<()> {
622 +
    fn write_commit(&self, out: &mut Vec<u8>, reference: &str, mark: u64, author: &str, secs: u64, message: &str, parents: &[String], snapshot: &Snapshot) -> Result<()> {
430 623
        out.extend_from_slice(format!("commit {reference}\nmark :{mark}\nauthor {author} {secs} +0000\ncommitter {author} {secs} +0000\ndata {}\n{message}\n", message.len()).as_bytes());
431 624
        for (i, p) in parents.iter().enumerate() {
432 -
            out.extend_from_slice(format!("{} :{p}\n", if i == 0 { "from" } else { "merge" }).as_bytes());
625 +
            out.extend_from_slice(format!("{} {p}\n", if i == 0 { "from" } else { "merge" }).as_bytes());
433 626
        }
434 627
        out.extend_from_slice(b"deleteall\n");
435 628
        for (path, f) in &snapshot.files {
…
15 unchanged lines
modified crates/oxy-core/src/repo/tests.rs +83 −0
…
1783 unchanged lines
1784 1784
    assert_eq!(similarity(b"", b"x\n"), 0);
1785 1785
}
1786 1786
1787 +
#[test]
1788 +
fn git_fetch_and_push_round_trip() {
1789 +
    hermetic();
1790 +
    if std::process::Command::new("git").arg("--version").output().is_err() {
1791 +
        eprintln!("git not installed; skipping");
1792 +
        return;
1793 +
    }
1794 +
    let dir = tempfile::tempdir().unwrap();
1795 +
    let root = dir.path();
1796 +
    let git_in = |d: &Path, args: &[&str]| -> String {
1797 +
        let out = std::process::Command::new("git")
1798 +
            .arg("-C")
1799 +
            .arg(d)
1800 +
            .args(["-c", "user.name=Grace", "-c", "[email protected]", "-c", "commit.gpgsign=false"])
1801 +
            .args(args)
1802 +
            .output()
1803 +
            .unwrap();
1804 +
        assert!(out.status.success(), "git {args:?}: {}", String::from_utf8_lossy(&out.stderr));
1805 +
        String::from_utf8_lossy(&out.stdout).trim().to_string()
1806 +
    };
1807 +
    // A bare "remote" and a work tree that pushes to it.
1808 +
    let remote = root.join("remote.git");
1809 +
    fs::create_dir_all(&remote).unwrap();
1810 +
    git_in(&remote, &["init", "-q", "--bare", "-b", "main"]);
1811 +
    let work = root.join("work");
1812 +
    git_in(root, &["clone", "-q", remote.to_str().unwrap(), work.to_str().unwrap()]);
1813 +
    git_in(&work, &["checkout", "-q", "-b", "main"]);
1814 +
    write(&work, "README.md", b"# Project\n");
1815 +
    write(&work, "src/lib.rs", b"pub fn a() {}\n");
1816 +
    git_in(&work, &["add", "."]);
1817 +
    git_in(&work, &["commit", "-q", "-m", "initial"]);
1818 +
    write(&work, "src/lib.rs", b"pub fn a() {}\npub fn b() {}\n");
1819 +
    git_in(&work, &["commit", "-q", "-am", "second"]);
1820 +
    git_in(&work, &["push", "-q", "origin", "main"]);
1821 +
    let tip1 = git_in(&work, &["rev-parse", "HEAD"]);
1822 +
1823 +
    // Fetch into a fresh oxy repository.
1824 +
    let o = root.join("oxy");
1825 +
    let mut repo = Repo::init(&o).unwrap().with_home(root.join("home"));
1826 +
    repo.create_identity("Ada", "[email protected]", IdentityScope::User).unwrap();
1827 +
    let url = remote.to_string_lossy().to_string();
1828 +
    let r = repo.git_fetch(&url, "main").unwrap();
1829 +
    assert_eq!((r.commits, r.heads), (2, 1));
1830 +
    assert_eq!(fs::read(o.join("src/lib.rs")).unwrap(), b"pub fn a() {}\npub fn b() {}\n");
1831 +
    assert!(!repo.snapshot("status").unwrap().changed);
1832 +
    assert_eq!(repo.git_fetch(&url, "main").unwrap().commits, 0, "nothing new");
1833 +
1834 +
    // An oxy change, pushed back: one commit on top of the Git tip.
1835 +
    write(&o, "src/lib.rs", b"pub fn a() {}\npub fn b() {}\npub fn c() {}\n");
1836 +
    write(&o, "NOTES.md", b"from oxy\n");
1837 +
    let h_oxy = repo.commit("add c and notes", &[]).unwrap();
1838 +
    let r = repo.git_push(&url, "main").unwrap();
1839 +
    assert_eq!(r.exported, 1, "{r:?}");
1840 +
    git_in(&work, &["pull", "-q", "--ff-only", "origin", "main"]);
1841 +
    assert_eq!(git_in(&work, &["log", "--format=%s", "main"]), "add c and notes\nsecond\ninitial");
1842 +
    assert_eq!(git_in(&work, &["rev-parse", "HEAD~1"]), tip1);
1843 +
    assert_eq!(fs::read(work.join("NOTES.md")).unwrap(), b"from oxy\n");
1844 +
    assert_eq!(git_in(&work, &["log", "-1", "--format=%an", "main"]), "Ada");
1845 +
    assert!(git_in(&work, &["log", "-1", "--format=%B", "main"]).contains(&format!("Oxy-Hash: {}", h_oxy.to_hex())));
1846 +
    assert_eq!(repo.git_push(&url, "main").unwrap().exported, 0, "already pushed");
1847 +
1848 +
    // Git moves on; pushing without fetching is refused, fetching brings
1849 +
    // the commit in and the working copy follows.
1850 +
    write(&work, "README.md", b"# Project\n\nMore.\n");
1851 +
    git_in(&work, &["commit", "-q", "-am", "readme from git"]);
1852 +
    git_in(&work, &["push", "-q", "origin", "main"]);
1853 +
    let tip2 = git_in(&work, &["rev-parse", "HEAD"]);
1854 +
    assert!(repo.git_push(&url, "main").unwrap_err().to_string().contains("fetch"));
1855 +
    let r = repo.git_fetch(&url, "main").unwrap();
1856 +
    assert_eq!((r.commits, r.heads), (1, 2), "the Git change and the oxy change commute");
1857 +
    assert_eq!(fs::read(o.join("README.md")).unwrap(), b"# Project\n\nMore.\n");
1858 +
    assert_eq!(fs::read(o.join("NOTES.md")).unwrap(), b"from oxy\n");
1859 +
    assert!(!repo.snapshot("status").unwrap().changed);
1860 +
    // Another oxy change pushes on top of Git's newest commit.
1861 +
    write(&o, "NOTES.md", b"from oxy\nagain\n");
1862 +
    repo.commit("notes again", &[]).unwrap();
1863 +
    assert_eq!(repo.git_push(&url, "main").unwrap().exported, 1);
1864 +
    git_in(&work, &["pull", "-q", "--ff-only", "origin", "main"]);
1865 +
    assert_eq!(git_in(&work, &["rev-parse", "HEAD~1"]), tip2);
1866 +
    assert_eq!(fs::read(work.join("NOTES.md")).unwrap(), b"from oxy\nagain\n");
1867 +
    assert_eq!(git_in(&work, &["log", "--format=%s", "main"]).lines().count(), 5);
1868 +
}
1869 +
modified docs/plan.md +2 −1
…
28 unchanged lines
29 29
- [x] User request (2026-09-27, mid-loop): make `oxy log` and all output look modern like `git log` with lines, spacing, and glyphs when the terminal supports them. (Done: rail-and-bullet log with `-n`/`-v`, relative times, authors, signature marks; status/show/op log/tags/workspaces/remotes/fetch/push/policy/identity restyled; ASCII fallback when piped or with `--plain`/`NO_COLOR`/non-UTF-8 locale.)
30 30
- [x] Identity lifecycle: `oxy identity rotate|revoke`, expiry, delegates (`oxy identity delegate add|remove`), multi-signer policy updates. (Done: rotation as supersession with chain-aware policy authorization, renew, delegates, revocation attestations and local markers, expiry checks, root add/remove/threshold, proposal files and `oxy policy sign`; spec 0.6n.)
31 31
- [x] Push and serve the chain of policies since the receiver's serial, so several offline rotations or updates publish in one push or fetch. (Done in iteration 12: `.oxy/policies/` index, `refs` history, bundle policies.)
32 -
- [ ] Rename detection on Git import (similarity ≥ 50%); `oxy git fetch`/`push` against a Git remote via a mirror.
32 +
- [x] Rename detection on Git import (similarity ≥ 50%); `oxy git fetch`/`push` against a Git remote via a mirror. (Done: detection lives in the change builder, so working-copy renames are tracked too; fetch/push through `.oxy/git/<key>/mirror.git` with fast-forward chaining; spec 0.6p.)
33 33
- [ ] Hardening: property tests for the CBOR decoder and pack reader against random and mutated inputs; a concurrent-push test against the server; a 1 GB single-file benchmark for chunking and blobs.
34 34
- [ ] Documentation: `docs/guide.md` (user guide by task) and `docs/protocol.md` (for other implementers, derived from spec §16 and the wire encodings).
35 35
- [ ] Spec review pass: resolve or re-word every "MAY ... until" allowance introduced during implementation, and close what can be closed in §27.
…
1 unchanged lines
37 37
## Log
38 38
39 39
- 2026-09-26 23:01 plan created.
40 +
- 2026-09-27 07:20 iteration 13: rename detection (snapshots and Git import) and oxy git fetch/push via a mirror (spec 0.6p). 108 tests.
40 41
- 2026-09-27 06:30 iteration 12: identity lifecycle and multi-signer policy updates; policy chains on fetch and push (spec 0.6n). 106 tests.
41 42
- 2026-09-27 05:45 iteration 11: modern CLI output across all commands (user request). 105 tests.
42 43
- 2026-09-27 05:15 iteration 10: orphaned references reified on rewrite (spec 0.6m); gc fixed to tolerate old views whose working copies were pruned under a zero op-retention during development (this repo's oldest 20 operations). 104 tests.
…
9 unchanged lines
modified docs/spec.md +6 −3
…
1 unchanged lines
2 2
3 3
| | |
4 4
|---|---|
5 -
| **Status** | Draft 0.6o |
5 +
| **Status** | Draft 0.6p |
6 6
| **Format version** | 1 |
7 7
| **Date** | 2026-09-26 |
8 8
| **Audience** | Implementers, security reviewers, and contributors |
…
772 unchanged lines
781 781
782 782
- **Import.** Each Git commit becomes one change whose edits are the diff between the union of its parents' states and its tree, so a merge commit becomes a resolution exactly when the parents conflict in the change model, and an ordinary change otherwise. The commit's author and hash are recorded as `Git-Author` and `Git-Commit` trailers; the author key is the zero KeyId, since nobody can sign on the original author's behalf. Submodule entries are skipped. Renames are detected on import exactly as for a snapshot (§7.1, similarity ≥ 50%), then tracked exactly. The Git commit's `Change-Id` trailer, if present and 128 bits, becomes the ChangeId; otherwise one is derived as BLAKE3-128 of the Git commit hash, so re-import is idempotent. Note that a change's own closure is what it depends on, not the Git snapshot: the imported bookmark's state equals the Git tree, but checking out an individual imported change yields only the files it and its dependencies touch.
783 783
- **Export.** Each state along a bookmark becomes a Git commit whose tree is the materialized state. The ChangeId is written as a `Change-Id:` trailer and the ChangeHash as `Oxy-Hash:`. Conflicted states are exported with §9.5 markers and an `Oxy-Conflicted: true` trailer. Signatures are not exported; Git commits MAY be additionally signed with the Ed25519 key via SSH signature format.
784 +
- **Fetch and push against a Git remote.** `oxy git fetch URL` keeps a bare mirror under `.oxy/git/` for the remote, fetches the branch into it, and imports the commits the bookmark does not have yet, seeding the import with the commits already known: an imported commit maps to its change, and a commit this repository exported maps to the bookmark state it was made from (so a Git commit on top of exported work is based on everything that work contained, including changes Git never saw). The bookmark moves to the union of its heads and the new changes, like a pull; the working copy follows when it was clean. A fetched state whose previous state was itself a Git tree is remembered as equal to the fetched tip. `oxy git push URL` refuses until the remote's tip is known, exports the bookmark's later states as commits chained onto that tip, and pushes the branch, so the push is always a fast-forward.
784 785
- **Round trip.** Import followed by export MUST reproduce every tree hash. Export followed by import MUST reproduce every ChangeId.
785 786
786 787
## 24. Command-line interface
…
15 unchanged lines
802 803
oxy identity create|show|export|rotate|renew|revoke|delegate
803 804
oxy policy init|show|grant|revoke|root|sign
804 805
oxy remote add|trust|list           oxy gc
805 -
oxy git import|export|fetch|push
806 +
oxy git import|export|fetch|push   (fetch and push go through a bare mirror under .oxy/git/)
806 807
```
807 808
808 809
Change arguments accept a ChangeId prefix, a ChangeHash prefix (with `#`), a bookmark, `@` for the working-copy change, and `@-` for its parent. Ambiguous prefixes are an error.
…
37 unchanged lines
846 847
847 848
---
848 849
849 -
*Changelog.* Draft 0.6o (2026-09-27): rename detection on snapshot and Git import records moves of the existing node (§7.1, §23).
850 +
*Changelog.* Draft 0.6p (2026-09-27): `oxy git fetch` and `oxy git push` against a Git remote through a mirror, with incremental import seeded by imported and exported commits (§23).
851 +
852 +
Draft 0.6o (2026-09-27): rename detection on snapshot and Git import records moves of the existing node (§7.1, §23).
850 853
851 854
Draft 0.6n (2026-09-27): identity lifecycle (§14.2): every identity edit is a rotation that supersedes rather than revokes, expiry bounds authenticated changes, revocations recorded locally; policy proposals for multi-signer updates, chain-aware authorization, bootstrap rule, and policy history on `refs` and in push bundles (§14.5, §16.3).
852 855
…
43 unchanged lines
modified src/cli/mod.rs +21 −2
…
282 unchanged lines
283 283
#[derive(Subcommand)]
284 284
enum GitCmd {
285 285
    /// Export the current bookmark to a Git repository (created bare if absent)
286 -
    Export { git_dir: PathBuf },
286 +
    Export {
287 +
        git_dir: PathBuf,
288 +
        /// Git branch to write (default: the bookmark name)
289 +
        #[arg(long)]
290 +
        branch: Option<String>,
291 +
    },
292 +
    /// Fetch a branch from a Git remote (path or URL) into the current bookmark
293 +
    Fetch {
294 +
        url: String,
295 +
        #[arg(long, default_value = "main")]
296 +
        branch: String,
297 +
    },
298 +
    /// Push the current bookmark to a branch of a Git remote (fetch first)
299 +
    Push {
300 +
        url: String,
301 +
        #[arg(long, default_value = "main")]
302 +
        branch: String,
303 +
    },
287 304
    /// Import a Git branch into a new Oxygen repository at DEST
288 305
    Import {
289 306
        git_dir: PathBuf,
…
125 unchanged lines
415 432
                DelegateCmd::List => identity::delegate_list(),
416 433
            },
417 434
        },
418 -
        Cmd::Git { cmd: GitCmd::Export { git_dir } } => sync::git_export(&git_dir),
435 +
        Cmd::Git { cmd: GitCmd::Export { git_dir, branch } } => sync::git_export(&git_dir, branch.as_deref()),
436 +
        Cmd::Git { cmd: GitCmd::Fetch { url, branch } } => sync::git_fetch(&url, &branch),
437 +
        Cmd::Git { cmd: GitCmd::Push { url, branch } } => sync::git_push(&url, &branch),
419 438
        Cmd::Git { cmd: GitCmd::Import { git_dir, dest, branch } } => sync::git_import(&git_dir, &dest, &branch),
420 439
        Cmd::Policy { cmd } => match cmd {
421 440
            PolicyCmd::Init => identity::policy_init(),
…
14 unchanged lines
modified src/cli/sync.rs +52 −2
…
140 unchanged lines
141 141
    Ok(0)
142 142
}
143 143
144 -
pub fn git_export(git_dir: &std::path::Path) -> Result<i32> {
144 +
pub fn git_export(git_dir: &std::path::Path, branch: Option<&str>) -> Result<i32> {
145 145
    let repo = open()?;
146 146
    let sp = ui::spinner("Exporting to Git…");
147 -
    let r = repo.git_export(git_dir)?;
147 +
    let r = match branch {
148 +
        Some(b) => repo.git_export_as(git_dir, b)?,
149 +
        None => repo.git_export(git_dir)?,
150 +
    };
148 151
    sp.clear();
149 152
    println!(
150 153
        "{} {} to {} as branch {} {}",
…
6 unchanged lines
157 160
    Ok(0)
158 161
}
159 162
163 +
pub fn git_fetch(url: &str, branch: &str) -> Result<i32> {
164 +
    let repo = open()?;
165 +
    let sp = ui::spinner(&format!("Fetching {url} {branch}…"));
166 +
    let r = repo.git_fetch(url, branch)?;
167 +
    sp.clear();
168 +
    if r.commits == 0 {
169 +
        println!("{} {}", ui::done("Fetched"), ui::dim(format!("nothing new from {url} {branch}")));
170 +
    } else {
171 +
        println!(
172 +
            "{} {} ({} merges) from {} {}{}{} now has {}",
173 +
            ui::done("Fetched"),
174 +
            plural(r.commits, "new commit"),
175 +
            r.merges,
176 +
            ui::path(url),
177 +
            ui::bookmark(branch),
178 +
            ui::sep(),
179 +
            ui::bookmark(&repo.config.bookmark),
180 +
            plural(r.heads, "head")
181 +
        );
182 +
        if !repo.snapshot("oxy git fetch")?.changed {
183 +
            println!("  {} {}", ui::note(), ui::dim("working copy updated"));
184 +
        } else {
185 +
            println!("  {} {}", ui::note(), ui::dim("run oxy checkout to update the working copy"));
186 +
        }
187 +
    }
188 +
    if r.skipped_submodules > 0 {
189 +
        println!("  {} {}", ui::note(), ui::dim(format!("skipped {} submodule entries", r.skipped_submodules)));
190 +
    }
191 +
    Ok(0)
192 +
}
193 +
194 +
pub fn git_push(url: &str, branch: &str) -> Result<i32> {
195 +
    let repo = open()?;
196 +
    let sp = ui::spinner(&format!("Pushing to {url} {branch}…"));
197 +
    let r = repo.git_push(url, branch)?;
198 +
    sp.clear();
199 +
    println!(
200 +
        "{} {} to {} {} {}",
201 +
        ui::done("Pushed"),
202 +
        plural(r.exported, "new commit"),
203 +
        ui::path(url),
204 +
        ui::bookmark(&r.branch),
205 +
        ui::dim(format!("({} already there)", r.skipped))
206 +
    );
207 +
    Ok(0)
208 +
}
209 +
160 210
pub fn git_import(git_dir: &std::path::Path, dest: &std::path::Path, branch: &str) -> Result<i32> {
161 211
    let repo = Repo::init(dest)?;
162 212
    let started = std::time::Instant::now();
…
30 unchanged lines