OXYGENHUB

Git bridge, export side: one commit per bookmark state via git fast-import, Change-Id/Oxy-Hash/Oxy-State trailers, incremental marks 79ab8528ff62

Author
Andy McCall @andymccall <[email protected]>
When
· 2026-09-26 20:31 UTC
Signature
✔ signed by Andy McCall
Hash
79ab8528ff62404fa16704012742b95c6594c6a4fcf08dfddebae0754ac59d6e
Depends on
614541c3cebd 83e0677b07fe 982864068a59 ba160f60219e fce44ae9ad29
Depended on by
c397a8c36a9f 6f4226bfb87f 544d0541a7e5 d6e0d5b8b339 d256e826ac5d 8b7986dd3cb0 579afd107b9b f0406c3b40ed ee6aa3e81bc8 ce118287d9da 810691ca6ce6 54e2e6309aa3 9935d490ddfb 33be24095b4b d45dab0484ea 58dfe0e134eb 73d592e763ab c3d2ef6104c2 fd280228bdc2 d2e3c39da1b1 fb5b6181618a 7eac74abc586 40a40bb7b798 e83354cdac22 2306a4d24779 ab4d4e31b38d 57d8dc05c9d4 b493d993a9df a87c7b9d563a 6f8aeb996558 dfb23a3a8cfa 958fb27dda7c 03571027793e 7891e21c762f 62794ecc7f74 75167672ebcd 4dee277c327a b7f102fcbb17 8f10a07a1dbe 30ea9c1e70fa 94084087a876 132b884b9a8d 135212de5370 c3069537d7c5 e8b4e156f2fa
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
8 atoms, 1 tree operation, 5 files

Browse the tree at this change

added crates/oxy-core/src/repo/git.rs +182 −0
1 +
//! Git bridge (spec §23), export side: each state the current bookmark
2 +
//! passed through becomes a Git commit through `git fast-import`, with the
3 +
//! state's full tree and the previous state's commit as parent. A state
4 +
//! that added exactly one change carries that change's message, author,
5 +
//! and `Change-Id`/`Oxy-Hash` trailers; every commit carries `Oxy-State`.
6 +
//!
7 +
//! Marks make re-exports incremental: `.oxy/git/<target>/git-marks` is
8 +
//! git's own marks file and `oxy-marks` maps state refs to marks.
9 +
10 +
use super::*;
11 +
use std::process::{Command, Stdio};
12 +
13 +
#[derive(Debug, Clone, Default, PartialEq, Eq)]
14 +
pub struct GitExportReport {
15 +
    pub exported: usize,
16 +
    pub skipped: usize,
17 +
    pub branch: String,
18 +
    pub heads: usize,
19 +
}
20 +
21 +
fn git(dir: &Path, args: &[&str]) -> Result<String> {
22 +
    let out = Command::new("git").arg("-C").arg(dir).args(args).output().map_err(|e| RepoError::Msg(format!("running git: {e}")))?;
23 +
    if !out.status.success() {
24 +
        return fail(format!("git {} failed: {}", args.join(" "), String::from_utf8_lossy(&out.stderr).trim()));
25 +
    }
26 +
    Ok(String::from_utf8_lossy(&out.stdout).trim().to_string())
27 +
}
28 +
29 +
impl Repo {
30 +
    fn git_marks_dir(&self, git_dir: &Path) -> Result<PathBuf> {
31 +
        let canonical = git_dir.canonicalize().unwrap_or_else(|_| git_dir.to_path_buf());
32 +
        let key = hash_object(ObjectType::Chunk, canonical.to_string_lossy().as_bytes()).short(16);
33 +
        let dir = self.oxy.join("git").join(key);
34 +
        fs::create_dir_all(&dir)?;
35 +
        Ok(dir)
36 +
    }
37 +
38 +
    fn read_oxy_marks(path: &Path) -> Result<BTreeMap<ObjectRef, u64>> {
39 +
        let mut out = BTreeMap::new();
40 +
        if let Ok(text) = fs::read_to_string(path) {
41 +
            for line in text.lines() {
42 +
                if let Some((h, m)) = line.split_once(' ') {
43 +
                    if let (Some(h), Ok(m)) = (ObjectRef::from_hex(h), m.parse()) {
44 +
                        out.insert(h, m);
45 +
                    }
46 +
                }
47 +
            }
48 +
        }
49 +
        Ok(out)
50 +
    }
51 +
52 +
    /// Export the current bookmark to the Git repository at `git_dir`,
53 +
    /// creating a bare one if nothing is there. Each state the bookmark
54 +
    /// passed through (per the operation log) becomes one commit whose tree
55 +
    /// is that state, so the Git branch is a linear history of full trees.
56 +
    pub fn git_export(&self, git_dir: &Path) -> Result<GitExportReport> {
57 +
        if !git_dir.join("HEAD").exists() && !git_dir.join(".git").exists() {
58 +
            fs::create_dir_all(git_dir)?;
59 +
            git(git_dir, &["init", "--bare", "-q"])?;
60 +
        }
61 +
        let marks_dir = self.git_marks_dir(git_dir)?;
62 +
        let git_marks = marks_dir.join("git-marks");
63 +
        let oxy_marks_path = marks_dir.join("oxy-marks");
64 +
        let mut marks = Self::read_oxy_marks(&oxy_marks_path)?;
65 +
        let mut next_mark = marks.values().copied().max().unwrap_or(0) + 1;
66 +
67 +
        let bookmark = self.config.bookmark.clone();
68 +
        let mut stream: Vec<u8> = Vec::new();
69 +
        let mut report = GitExportReport { branch: bookmark.clone(), heads: self.bookmark_heads()?.len(), ..Default::default() };
70 +
71 +
        // States along the bookmark, oldest first, skipping unchanged and pruned ones.
72 +
        let mut ops = self.op_log()?;
73 +
        ops.reverse();
74 +
        let mut prev: Option<(ObjectRef, Vec<ChangeHash>)> = None;
75 +
        let mut last_mark: Option<u64> = None;
76 +
        for (_, op) in &ops {
77 +
            let Some(state_ref) = op.view.bookmarks.get(&bookmark).copied() else { continue };
78 +
            if prev.as_ref().is_some_and(|(p, _)| *p == state_ref) {
79 +
                continue;
80 +
            }
81 +
            let Ok(state) = self.store.get_state(&state_ref) else { continue };
82 +
            if state.heads.is_empty() {
83 +
                prev = Some((state_ref, vec![]));
84 +
                continue;
85 +
            }
86 +
            if let Some(m) = marks.get(&state_ref) {
87 +
                report.skipped += 1;
88 +
                last_mark = Some(*m);
89 +
                prev = Some((state_ref, state.heads.clone()));
90 +
                continue;
91 +
            }
92 +
            let prev_heads = prev.as_ref().map(|(_, h)| h.clone()).unwrap_or_default();
93 +
            let added = self.negotiate(&prev_heads, &state.heads).unwrap_or_default();
94 +
            let loaded = self.load_state(&state.heads)?;
95 +
            let snapshot = loaded.materialize();
96 +
            let (author, secs, mut message) = match added.as_slice() {
97 +
                [h] => {
98 +
                    let c = self.store.get_change(h)?;
99 +
                    let mut m = c.message.clone();
100 +
                    if !m.ends_with('\n') {
101 +
                        m.push('\n');
102 +
                    }
103 +
                    m.push_str(&format!("\nChange-Id: {}\nOxy-Hash: {}\n", c.change_id.to_hex(), h.to_hex()));
104 +
                    (self.git_author(&c), c.hlc.wall_ms / 1000, m)
105 +
                }
106 +
                [] => ("oxy <oxy@localhost>".to_string(), op.hlc.wall_ms / 1000, format!("oxy: {}\n\n", op.command)),
107 +
                many => {
108 +
                    let mut m = format!("oxy: {} ({} changes)\n\n", op.command, many.len());
109 +
                    for h in many {
110 +
                        let c = self.store.get_change(h)?;
111 +
                        m.push_str(&format!("- {} {}\n", c.change_id.short(8), c.message.lines().next().unwrap_or("")));
112 +
                    }
113 +
                    m.push_str(&format!("\nOxy-Changes: {}\n", many.iter().map(|h| h.to_hex()).collect::<Vec<_>>().join(" ")));
114 +
                    ("oxy <oxy@localhost>".to_string(), op.hlc.wall_ms / 1000, m)
115 +
                }
116 +
            };
117 +
            message.push_str(&format!("Oxy-State: {}\n", state_ref.to_hex()));
118 +
            if snapshot.is_conflicted() {
119 +
                message.push_str("Oxy-Conflicted: true\n");
120 +
            }
121 +
            let mark = next_mark;
122 +
            next_mark += 1;
123 +
            let parents: Vec<u64> = last_mark.into_iter().collect();
124 +
            self.write_commit(&mut stream, "refs/oxy/export", mark, &author, secs, &message, &parents, &snapshot)?;
125 +
            marks.insert(state_ref, mark);
126 +
            last_mark = Some(mark);
127 +
            prev = Some((state_ref, state.heads.clone()));
128 +
            report.exported += 1;
129 +
        }
130 +
        match last_mark {
131 +
            Some(m) => stream.extend_from_slice(format!("reset refs/heads/{bookmark}\nfrom :{m}\n\ndone\n").as_bytes()),
132 +
            None => stream.extend_from_slice(b"done\n"),
133 +
        }
134 +
135 +
        let mut cmd = Command::new("git");
136 +
        // `--force`: the scratch ref may jump between unrelated states.
137 +
        cmd.arg("-C").arg(git_dir).args(["fast-import", "--quiet", "--done", "--force"]).arg(format!("--export-marks={}", git_marks.display()));
138 +
        if git_marks.exists() {
139 +
            cmd.arg(format!("--import-marks={}", git_marks.display()));
140 +
        }
141 +
        let mut child = cmd.stdin(Stdio::piped()).stdout(Stdio::null()).stderr(Stdio::piped()).spawn().map_err(|e| RepoError::Msg(format!("running git fast-import: {e}")))?;
142 +
        child.stdin.take().unwrap().write_all(&stream)?;
143 +
        let out = child.wait_with_output()?;
144 +
        if !out.status.success() {
145 +
            return fail(format!("git fast-import failed: {}", String::from_utf8_lossy(&out.stderr).trim()));
146 +
        }
147 +
        let text: String = marks.iter().map(|(h, m)| format!("{} {m}\n", h.to_hex())).collect();
148 +
        write_atomic(&oxy_marks_path, text.as_bytes())?;
149 +
        let _ = git(git_dir, &["update-ref", "-d", "refs/oxy/export"]);
150 +
        Ok(report)
151 +
    }
152 +
153 +
    fn git_author(&self, c: &Change) -> String {
154 +
        match self.store.get_identity(&c.author) {
155 +
            Ok(id) if !id.name.is_empty() => format!("{} <{}>", id.name.replace(['<', '>', '\n'], " "), id.email.replace(['<', '>', '\n', ' '], "")),
156 +
            _ => "oxy <oxy@localhost>".to_string(),
157 +
        }
158 +
    }
159 +
160 +
    #[allow(clippy::too_many_arguments)]
161 +
    fn write_commit(&self, out: &mut Vec<u8>, reference: &str, mark: u64, author: &str, secs: u64, message: &str, parents: &[u64], snapshot: &Snapshot) -> Result<()> {
162 +
        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());
163 +
        for (i, p) in parents.iter().enumerate() {
164 +
            out.extend_from_slice(format!("{} :{p}\n", if i == 0 { "from" } else { "merge" }).as_bytes());
165 +
        }
166 +
        out.extend_from_slice(b"deleteall\n");
167 +
        for (path, f) in &snapshot.files {
168 +
            let (mode, bytes): (&str, Vec<u8>) = match (&f.kind, &f.content) {
169 +
                (NodeKind::Dir, _) => continue,
170 +
                (NodeKind::Symlink, FileContent::Opaque { blobs }) => ("120000", blobs.first().map(|b| self.read_blob(b)).transpose()?.unwrap_or_default()),
171 +
                (_, FileContent::Text { bytes, .. }) => (if f.mode.exec { "100755" } else { "100644" }, bytes.clone()),
172 +
                (_, FileContent::Opaque { blobs }) => (if f.mode.exec { "100755" } else { "100644" }, blobs.first().map(|b| self.read_blob(b)).transpose()?.unwrap_or_default()),
173 +
                (_, FileContent::Dir) => continue,
174 +
            };
175 +
            out.extend_from_slice(format!("M {mode} inline {path}\ndata {}\n", bytes.len()).as_bytes());
176 +
            out.extend_from_slice(&bytes);
177 +
            out.push(b'\n');
178 +
        }
179 +
        out.push(b'\n');
180 +
        Ok(())
181 +
    }
182 +
}
modified crates/oxy-core/src/repo/mod.rs +2 −0
…
53 unchanged lines
54 54
mod checkout;
55 55
mod config;
56 56
mod gc;
57 +
mod git;
57 58
mod history;
58 59
mod identity;
59 60
mod policy;
…
5 unchanged lines
65 66
pub use checkout::CheckoutReport;
66 67
pub use config::{read_config_file, Config};
67 68
pub use gc::GcReport;
69 +
pub use git::GitExportReport;
68 70
pub use identity::SignatureStatus;
69 71
pub use snapshot::{added_node_paths, has_conflict_markers, is_binary, referenced_changes, render_change, ChangeStatus, PathStatus, SnapshotResult};
70 72
pub use sync::{FetchReport, PullReport};
…
502 unchanged lines
modified crates/oxy-core/src/repo/tests.rs +64 −0
…
870 unchanged lines
871 871
    repo.undo().unwrap();
872 872
    assert_eq!(repo.bookmark_heads().unwrap(), vec![h2d]);
873 873
}
874 +
875 +
#[test]
876 +
fn git_export_mirrors_history() {
877 +
    hermetic();
878 +
    if std::process::Command::new("git").arg("--version").output().is_err() {
879 +
        eprintln!("git not installed; skipping");
880 +
        return;
881 +
    }
882 +
    let dir = tempfile::tempdir().unwrap();
883 +
    let root = dir.path();
884 +
    let mut repo = Repo::init(&root.join("r")).unwrap().with_home(root.join("home"));
885 +
    repo.create_identity("Ada Lovelace", "[email protected]", IdentityScope::User).unwrap();
886 +
    let r = root.join("r");
887 +
    write(&r, "README.md", b"hello\n");
888 +
    write(&r, "src/main.rs", b"fn main() {}\n");
889 +
    let h1 = repo.commit("first", &[]).unwrap();
890 +
    write(&r, "src/main.rs", b"fn main() { run(); }\n");
891 +
    write(&r, "logo.png", b"\x89PNG\0\0");
892 +
    let h2 = repo.commit("second", &[]).unwrap();
893 +
894 +
    let git_dir = root.join("mirror.git");
895 +
    let rep = repo.git_export(&git_dir).unwrap();
896 +
    assert_eq!((rep.exported, rep.skipped, rep.heads), (2, 0, 1));
897 +
    let git = |args: &[&str]| -> String {
898 +
        let out = std::process::Command::new("git").arg("-C").arg(&git_dir).args(args).output().unwrap();
899 +
        assert!(out.status.success(), "{}", String::from_utf8_lossy(&out.stderr));
900 +
        String::from_utf8_lossy(&out.stdout).trim().to_string()
901 +
    };
902 +
    assert_eq!(git(&["log", "--format=%s", "main"]), "second\nfirst");
903 +
    assert_eq!(git(&["show", "main:src/main.rs"]), "fn main() { run(); }");
904 +
    assert_eq!(git(&["show", "main:README.md"]), "hello");
905 +
    let body = git(&["log", "-1", "--format=%B", "main"]);
906 +
    assert!(body.contains(&format!("Oxy-Hash: {}", h2.to_hex())), "{body}");
907 +
    assert!(body.contains("Change-Id: "), "{body}");
908 +
    assert_eq!(git(&["log", "-1", "--format=%an <%ae>", "main"]), "Ada Lovelace <[email protected]>");
909 +
    assert_eq!(git(&["ls-tree", "--name-only", "main"]), "README.md\nlogo.png\nsrc");
910 +
911 +
    // Re-export is incremental.
912 +
    let rep = repo.git_export(&git_dir).unwrap();
913 +
    assert_eq!((rep.exported, rep.skipped), (0, 2));
914 +
915 +
    // An independent change makes two heads; the next state is one more
916 +
    // commit on the linear branch whose tree is the union.
917 +
    repo.checkout(&[h1], false, "checkout").unwrap();
918 +
    write(&r, "other.txt", b"o\n");
919 +
    let h3 = repo.commit("third", &[]).unwrap();
920 +
    let rep = repo.git_export(&git_dir).unwrap();
921 +
    assert_eq!((rep.exported, rep.heads), (1, 2));
922 +
    assert_eq!(git(&["log", "--format=%s", "main"]), "third\nsecond\nfirst");
923 +
    let parents = git(&["rev-list", "--parents", "-1", "main"]);
924 +
    assert_eq!(parents.split(' ').count(), 2, "one parent: {parents}");
925 +
    assert_eq!(git(&["show", "main:other.txt"]), "o");
926 +
    assert_eq!(git(&["show", "main:src/main.rs"]), "fn main() { run(); }");
927 +
    assert_eq!(git(&["ls-tree", "--name-only", "main"]), "README.md\nlogo.png\nother.txt\nsrc");
928 +
    let body = git(&["log", "-1", "--format=%B", "main"]);
929 +
    assert!(body.contains(&format!("Oxy-Hash: {}", h3.to_hex())) && body.contains("Oxy-State: "), "{body}");
930 +
    // A rewrite is a new state whose one new change is the rewritten one.
931 +
    repo.describe(&h3, "third, described").unwrap();
932 +
    let rep = repo.git_export(&git_dir).unwrap();
933 +
    assert_eq!(rep.exported, 1);
934 +
    assert_eq!(git(&["log", "-1", "--format=%s", "main"]), "third, described");
935 +
    assert_eq!(git(&["log", "--format=%s", "main"]).lines().count(), 4);
936 +
    let _ = h2;
937 +
}
modified src/cli/mod.rs +12 −0
…
114 unchanged lines
115 115
    },
116 116
    /// Verify the signatures on a change (default: every change in the log)
117 117
    Verify { change: Option<String> },
118 +
    /// Git interoperability
119 +
    Git {
120 +
        #[command(subcommand)]
121 +
        cmd: GitCmd,
122 +
    },
118 123
    /// Manage the trust policy (who may update which bookmarks)
119 124
    Policy {
120 125
        #[command(subcommand)]
…
40 unchanged lines
161 166
}
162 167
163 168
#[derive(Subcommand)]
169 +
enum GitCmd {
170 +
    /// Export the current bookmark to a Git repository (created bare if absent)
171 +
    Export { git_dir: PathBuf },
172 +
}
173 +
174 +
#[derive(Subcommand)]
164 175
enum PolicyCmd {
165 176
    /// Create the initial policy with your identity as the only root
166 177
    Init,
…
48 unchanged lines
215 226
            IdentityCmd::Show => identity::show(),
216 227
            IdentityCmd::Export { path } => identity::export(path),
217 228
        },
229 +
        Cmd::Git { cmd: GitCmd::Export { git_dir } } => sync::git_export(&git_dir),
218 230
        Cmd::Policy { cmd } => match cmd {
219 231
            PolicyCmd::Init => identity::policy_init(),
220 232
            PolicyCmd::Show => identity::policy_show(),
…
7 unchanged lines
modified src/cli/sync.rs +15 −0
…
116 unchanged lines
117 117
    oxy_server::run_blocking(listener, oxy_server::ServerConfig { root, tokens, tls })?;
118 118
    Ok(0)
119 119
}
120 +
121 +
pub fn git_export(git_dir: &std::path::Path) -> Result<i32> {
122 +
    let repo = open()?;
123 +
    let r = repo.git_export(git_dir)?;
124 +
    println!(
125 +
        "Exported {} to {} (branch {}: {}{}); {} already exported.",
126 +
        plural(r.exported, "change"),
127 +
        git_dir.display(),
128 +
        r.branch,
129 +
        plural(r.heads, "head"),
130 +
        if r.heads > 1 { ", joined by a synthetic merge commit" } else { "" },
131 +
        r.skipped
132 +
    );
133 +
    Ok(0)
134 +
}