OXYGENHUB

Git bridge, import side: oxy git import with merge commits as resolutions, Change-Id trailers, batched blob reads c3069537d7c5

Author
Andy McCall @andymccall <[email protected]>
When
· 2026-09-26 20:58 UTC
Signature
✔ signed by Andy McCall
Hash
c3069537d7c531415b399c0bd455e13708be6aa2a706d0dd5da0128569adee5e
Depends on
55e5a16a2b9c 614541c3cebd 79ab8528ff62 83e0677b07fe 982864068a59 ba160f60219e fce44ae9ad29 ff0af1ee299d
Depended on by
284fe47d98b9 d6e0d5b8b339 579afd107b9b ce118287d9da 54e2e6309aa3 9935d490ddfb 58dfe0e134eb 73d592e763ab c3d2ef6104c2 360aa74a6277 fd280228bdc2 fb5b6181618a 992467d31d26 23ff1288a5dd 40a40bb7b798 e83354cdac22 2306a4d24779 b493d993a9df 6f8aeb996558 dfb23a3a8cfa 314db9b4f3f8 958fb27dda7c 03571027793e 7df9007b163c c863c7a7a2d1 62794ecc7f74 d42369abf180 119bbf50cb86 75167672ebcd 4dee277c327a b7f102fcbb17 8f10a07a1dbe 30ea9c1e70fa 6e723b81f5b9 94084087a876 28fc23d7706a cdb5beba7f05 2863456a0f6d 135212de5370 252e8cb23fbc
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
14 atoms, 0 tree operations, 7 files

Browse the tree at this change

modified crates/oxy-core/src/repo/git.rs +249 −0
…
10 unchanged lines
11 11
use std::process::{Command, Stdio};
12 12
13 13
#[derive(Debug, Clone, Default, PartialEq, Eq)]
14 +
pub struct GitImportReport {
15 +
    pub commits: usize,
16 +
    pub merges: usize,
17 +
    pub skipped_submodules: usize,
18 +
    pub heads: usize,
19 +
}
20 +
21 +
/// A running `git cat-file --batch`, for reading many blobs cheaply.
22 +
struct CatFile {
23 +
    child: std::process::Child,
24 +
    stdin: std::process::ChildStdin,
25 +
    stdout: std::io::BufReader<std::process::ChildStdout>,
26 +
}
27 +
28 +
impl CatFile {
29 +
    fn start(git_dir: &Path) -> Result<CatFile> {
30 +
        let mut child = Command::new("git")
31 +
            .arg("-C")
32 +
            .arg(git_dir)
33 +
            .args(["cat-file", "--batch"])
34 +
            .stdin(Stdio::piped())
35 +
            .stdout(Stdio::piped())
36 +
            .stderr(Stdio::null())
37 +
            .spawn()
38 +
            .map_err(|e| RepoError::Msg(format!("running git cat-file: {e}")))?;
39 +
        let stdin = child.stdin.take().unwrap();
40 +
        let stdout = std::io::BufReader::new(child.stdout.take().unwrap());
41 +
        Ok(CatFile { child, stdin, stdout })
42 +
    }
43 +
44 +
    fn read(&mut self, sha: &str) -> Result<Vec<u8>> {
45 +
        use std::io::{BufRead, Read};
46 +
        writeln!(self.stdin, "{sha}")?;
47 +
        self.stdin.flush()?;
48 +
        let mut header = String::new();
49 +
        self.stdout.read_line(&mut header)?;
50 +
        let parts: Vec<&str> = header.trim_end().split(' ').collect();
51 +
        if parts.len() != 3 {
52 +
            return fail(format!("git cat-file: object {sha} not found"));
53 +
        }
54 +
        let size: usize = parts[2].parse().map_err(|_| RepoError::Msg("git cat-file: bad size".into()))?;
55 +
        let mut body = vec![0u8; size];
56 +
        self.stdout.read_exact(&mut body)?;
57 +
        let mut nl = [0u8; 1];
58 +
        self.stdout.read_exact(&mut nl)?;
59 +
        Ok(body)
60 +
    }
61 +
}
62 +
63 +
impl Drop for CatFile {
64 +
    fn drop(&mut self) {
65 +
        let _ = self.child.kill();
66 +
        let _ = self.child.wait();
67 +
    }
68 +
}
69 +
70 +
/// One commit as `git log` describes it.
71 +
struct GitCommit {
72 +
    sha: String,
73 +
    parents: Vec<String>,
74 +
    author_secs: u64,
75 +
    author: String,
76 +
    message: String,
77 +
}
78 +
79 +
fn list_commits(git_dir: &Path, branch: &str) -> Result<Vec<GitCommit>> {
80 +
    let out = Command::new("git")
81 +
        .arg("-C")
82 +
        .arg(git_dir)
83 +
        .args(["log", "--reverse", "--topo-order", "--format=%H%x00%P%x00%at%x00%an <%ae>%x00%B%x1e", branch])
84 +
        .output()
85 +
        .map_err(|e| RepoError::Msg(format!("running git log: {e}")))?;
86 +
    if !out.status.success() {
87 +
        return fail(format!("git log {branch} failed: {}", String::from_utf8_lossy(&out.stderr).trim()));
88 +
    }
89 +
    let text = String::from_utf8_lossy(&out.stdout);
90 +
    let mut commits = Vec::new();
91 +
    for record in text.split('\x1e') {
92 +
        let record = record.trim_start_matches('\n');
93 +
        if record.trim().is_empty() {
94 +
            continue;
95 +
        }
96 +
        let f: Vec<&str> = record.splitn(5, '\0').collect();
97 +
        if f.len() != 5 {
98 +
            return fail("unexpected git log record");
99 +
        }
100 +
        commits.push(GitCommit {
101 +
            sha: f[0].to_string(),
102 +
            parents: f[1].split_whitespace().map(str::to_string).collect(),
103 +
            author_secs: f[2].parse().unwrap_or(0),
104 +
            author: f[3].to_string(),
105 +
            message: f[4].trim_end().to_string(),
106 +
        });
107 +
    }
108 +
    Ok(commits)
109 +
}
110 +
111 +
/// `path → (mode, blob sha)` for a commit's tree.
112 +
fn list_tree(git_dir: &Path, sha: &str) -> Result<BTreeMap<String, (String, String)>> {
113 +
    let text = git(git_dir, &["ls-tree", "-r", "-z", sha])?;
114 +
    let mut out = BTreeMap::new();
115 +
    for entry in text.split('\0') {
116 +
        if entry.is_empty() {
117 +
            continue;
118 +
        }
119 +
        let Some((meta, path)) = entry.split_once('\t') else { continue };
120 +
        let m: Vec<&str> = meta.split(' ').collect();
121 +
        if m.len() == 3 {
122 +
            out.insert(path.to_string(), (m[0].to_string(), m[2].to_string()));
123 +
        }
124 +
    }
125 +
    Ok(out)
126 +
}
127 +
128 +
/// The ChangeId for a Git commit: its `Change-Id` trailer if it has a
129 +
/// 32-hex one, else BLAKE3-128 of the commit hash (spec §23).
130 +
fn change_id_for(sha: &str, message: &str) -> ChangeId {
131 +
    for line in message.lines().rev() {
132 +
        if let Some(v) = line.strip_prefix("Change-Id:") {
133 +
            if let Some(id) = ChangeId::from_hex(v.trim()) {
134 +
                return id;
135 +
            }
136 +
        }
137 +
    }
138 +
    let h = blake3::hash(sha.as_bytes());
139 +
    ChangeId(h.as_bytes()[..16].try_into().unwrap())
140 +
}
141 +
142 +
#[derive(Debug, Clone, Default, PartialEq, Eq)]
14 143
pub struct GitExportReport {
15 144
    pub exported: usize,
16 145
    pub skipped: usize,
…
133 unchanged lines
150 279
        Ok(report)
151 280
    }
152 281
282 +
    /// Import branch `branch` of the Git repository at `git_dir` into this
283 +
    /// (empty) repository's bookmark (spec §23): one change per commit, its
284 +
    /// edits the difference between the union of its parents' states and its
285 +
    /// tree, so merge commits become resolutions. Submodules are skipped.
286 +
    pub fn import_git(&self, git_dir: &Path, branch: &str) -> Result<GitImportReport> {
287 +
        if !self.bookmark_heads()?.is_empty() {
288 +
            return fail("git import needs an empty bookmark; import into a fresh repository");
289 +
        }
290 +
        let commits = list_commits(git_dir, branch)?;
291 +
        let mut cat = CatFile::start(git_dir)?;
292 +
        let mut report = GitImportReport::default();
293 +
        let mut change_of: HashMap<String, ChangeHash> = HashMap::new();
294 +
        // The state after the most recently imported commit, reused when the
295 +
        // next commit's first parent is that commit.
296 +
        let mut last: Option<(String, State)> = None;
297 +
        let mut blob_cache: HashMap<String, Vec<u8>> = HashMap::new();
298 +
        let mut cache_bytes: usize = 0;
299 +
300 +
        for gc in &commits {
301 +
            // Base: union of every parent's state.
302 +
            let mut base = match (&gc.parents.first(), &last) {
303 +
                (Some(p1), Some((sha, st))) if *p1 == sha => st.clone(),
304 +
                (Some(p1), _) => self.git_ancestor_state(git_dir, &[p1.as_str()], &change_of)?,
305 +
                (None, _) => State::new(),
306 +
            };
307 +
            if gc.parents.len() > 1 {
308 +
                report.merges += 1;
309 +
                let mut args: Vec<String> = gc.parents[1..].to_vec();
310 +
                args.push(format!("^{}", gc.parents[0]));
311 +
                let extra = git(git_dir, &["rev-list".to_string()].iter().map(String::as_str).chain(args.iter().map(String::as_str)).collect::<Vec<_>>())?;
312 +
                for sha in extra.lines() {
313 +
                    if let Some(h) = change_of.get(sha) {
314 +
                        base.add_unchecked(self.store.get_change(h)?);
315 +
                    }
316 +
                }
317 +
            }
318 +
            let base_snapshot = base.materialize();
319 +
320 +
            // The commit's tree as in-memory entries.
321 +
            let mut entries: BTreeMap<String, FsEntry> = BTreeMap::new();
322 +
            for (path, (mode, sha)) in list_tree(git_dir, &gc.sha)? {
323 +
                let (kind, exec) = match mode.as_str() {
324 +
                    "100644" | "100664" => (NodeKind::File, false),
325 +
                    "100755" => (NodeKind::File, true),
326 +
                    "120000" => (NodeKind::Symlink, false),
327 +
                    "160000" => {
328 +
                        report.skipped_submodules += 1;
329 +
                        continue;
330 +
                    }
331 +
                    _ => continue,
332 +
                };
333 +
                let content = match blob_cache.get(&sha) {
334 +
                    Some(c) => c.clone(),
335 +
                    None => {
336 +
                        let c = cat.read(&sha)?;
337 +
                        if cache_bytes + c.len() > 256 * 1024 * 1024 {
338 +
                            blob_cache.clear();
339 +
                            cache_bytes = 0;
340 +
                        }
341 +
                        cache_bytes += c.len();
342 +
                        blob_cache.insert(sha.clone(), c.clone());
343 +
                        c
344 +
                    }
345 +
                };
346 +
                entries.insert(path, FsEntry { kind, exec, content });
347 +
            }
348 +
349 +
            let built = self.build_from_entries(&base, &base_snapshot, HashMap::new(), &entries)?;
350 +
            let mut deps: BTreeSet<ChangeHash> = built.contributors.iter().copied().collect();
351 +
            let mut message = gc.message.clone();
352 +
            if !message.ends_with('\n') {
353 +
                message.push('\n');
354 +
            }
355 +
            message.push_str(&format!("\nGit-Commit: {}\nGit-Author: {}\n", gc.sha, gc.author));
356 +
            let mut change = Change {
357 +
                change_id: change_id_for(&gc.sha, &gc.message),
358 +
                deps: vec![],
359 +
                author: ObjectRef::ZERO,
360 +
                hlc: Hlc { wall_ms: gc.author_secs * 1000, logical: 0 },
361 +
                message,
362 +
                tree_ops: built.tree_ops,
363 +
                edits: built.edits,
364 +
                kind: if built.resolves { ChangeKind::Resolution } else { ChangeKind::Normal },
365 +
            };
366 +
            deps.extend(referenced_changes(&change, &base));
367 +
            change.deps = deps.into_iter().collect();
368 +
            change.normalize();
369 +
            let hash = self.store.put_change(&change)?;
370 +
            base.add_unchecked(change);
371 +
            change_of.insert(gc.sha.clone(), hash);
372 +
            last = Some((gc.sha.clone(), base));
373 +
            report.commits += 1;
374 +
        }
375 +
376 +
        // The bookmark: heads of the tip's state; the working copy follows.
377 +
        let Some((_, tip_state)) = last else { return Ok(report) };
378 +
        let heads = tip_state.heads();
379 +
        report.heads = heads.len();
380 +
        let (op_ref, op) = self.head_op()?;
381 +
        let mut view = op.view.clone();
382 +
        view.bookmarks.insert(self.config.bookmark.clone(), self.store.put_state(&StateObject::new(heads.clone()))?);
383 +
        self.record_op(&[op_ref], &format!("oxy git import {} {branch}", git_dir.display()), view)?;
384 +
        self.checkout(&heads, true, "oxy git import (checkout)")?;
385 +
        Ok(report)
386 +
    }
387 +
388 +
    /// The state holding the changes of every Git ancestor of `shas`.
389 +
    fn git_ancestor_state(&self, git_dir: &Path, shas: &[&str], change_of: &HashMap<String, ChangeHash>) -> Result<State> {
390 +
        let mut args = vec!["rev-list"];
391 +
        args.extend(shas);
392 +
        let out = git(git_dir, &args)?;
393 +
        let mut st = State::new();
394 +
        for sha in out.lines() {
395 +
            if let Some(h) = change_of.get(sha) {
396 +
                st.add_unchecked(self.store.get_change(h)?);
397 +
            }
398 +
        }
399 +
        Ok(st)
400 +
    }
401 +
153 402
    fn git_author(&self, c: &Change) -> String {
154 403
        match self.store.get_identity(&c.author) {
155 404
            Ok(id) if !id.name.is_empty() => format!("{} <{}>", id.name.replace(['<', '>', '\n'], " "), id.email.replace(['<', '>', '\n', ' '], "")),
…
27 unchanged lines
modified crates/oxy-core/src/repo/mod.rs +7 −5
…
65 unchanged lines
66 66
pub use checkout::CheckoutReport;
67 67
pub use config::{read_config_file, Config};
68 68
pub use gc::GcReport;
69 -
pub use git::GitExportReport;
69 +
pub use git::{GitExportReport, GitImportReport};
70 70
pub use identity::SignatureStatus;
71 71
pub use snapshot::{added_node_paths, has_conflict_markers, is_binary, referenced_changes, render_change, ChangeStatus, PathStatus, SnapshotResult};
72 72
pub use sync::{FetchReport, PullReport};
…
16 unchanged lines
89 89
    pub is_head: bool,
90 90
}
91 91
92 -
struct FsEntry {
93 -
    kind: NodeKind,
94 -
    exec: bool,
95 -
    content: Vec<u8>,
92 +
/// A file as found on disk or in an imported tree.
93 +
pub(crate) struct FsEntry {
94 +
    pub kind: NodeKind,
95 +
    pub exec: bool,
96 +
    /// File content, or a symlink's target.
97 +
    pub content: Vec<u8>,
96 98
}
97 99
98 100
pub struct Repo {
…
476 unchanged lines
modified crates/oxy-core/src/repo/snapshot.rs +6 −1
…
61 unchanged lines
62 62
    /// NodeIds an earlier version of the same change assigned to new paths.
63 63
    pub(crate) fn build_against(&self, base: &State, base_snapshot: &Snapshot, reuse: HashMap<String, (NodeId, NodeKind)>) -> Result<BuiltChange> {
64 64
        let fs_entries = self.walk_working_copy()?;
65 +
        self.build_from_entries(base, base_snapshot, reuse, &fs_entries)
66 +
    }
67 +
68 +
    /// Like `build_against`, for files given in memory (imports).
69 +
    pub(crate) fn build_from_entries(&self, base: &State, base_snapshot: &Snapshot, reuse: HashMap<String, (NodeId, NodeKind)>, fs_entries: &BTreeMap<String, FsEntry>) -> Result<BuiltChange> {
65 70
        let mut b = ChangeBuilder::new(base, base_snapshot, reuse);
66 -
        for (path, fs_entry) in &fs_entries {
71 +
        for (path, fs_entry) in fs_entries {
67 72
            match base_snapshot.files.get(path) {
68 73
                None => b.add_path(self, path, fs_entry)?,
69 74
                Some(expected) => b.update_path(self, path, fs_entry, expected)?,
…
511 unchanged lines
modified crates/oxy-core/src/repo/tests.rs +97 −0
…
934 unchanged lines
935 935
    assert_eq!(git(&["log", "--format=%s", "main"]).lines().count(), 4);
936 936
    let _ = h2;
937 937
}
938 +
939 +
#[test]
940 +
fn git_import_reproduces_trees() {
941 +
    hermetic();
942 +
    if std::process::Command::new("git").arg("--version").output().is_err() {
943 +
        eprintln!("git not installed; skipping");
944 +
        return;
945 +
    }
946 +
    let dir = tempfile::tempdir().unwrap();
947 +
    let root = dir.path();
948 +
    let g = root.join("src.git");
949 +
    fs::create_dir_all(&g).unwrap();
950 +
    let git = |args: &[&str]| -> String {
951 +
        let out = std::process::Command::new("git")
952 +
            .arg("-C")
953 +
            .arg(&g)
954 +
            .args(["-c", "user.name=Grace", "-c", "[email protected]", "-c", "commit.gpgsign=false"])
955 +
            .args(args)
956 +
            .output()
957 +
            .unwrap();
958 +
        assert!(out.status.success(), "git {args:?}: {}", String::from_utf8_lossy(&out.stderr));
959 +
        String::from_utf8_lossy(&out.stdout).trim().to_string()
960 +
    };
961 +
    git(&["init", "-q", "-b", "main"]);
962 +
    write(&g, "README.md", b"# Hi\n");
963 +
    write(&g, "src/lib.rs", b"pub fn a() {}\n");
964 +
    git(&["add", "."]);
965 +
    git(&["commit", "-q", "-m", "initial"]);
966 +
    write(&g, "src/lib.rs", b"pub fn a() {}\npub fn b() {}\n");
967 +
    write(&g, "run.sh", b"#!/bin/sh\n");
968 +
    #[cfg(unix)]
969 +
    {
970 +
        use std::os::unix::fs::PermissionsExt;
971 +
        fs::set_permissions(g.join("run.sh"), fs::Permissions::from_mode(0o755)).unwrap();
972 +
    }
973 +
    write(&g, "logo.png", b"\x89PNG\0\0\x01");
974 +
    git(&["add", "."]);
975 +
    git(&["commit", "-q", "-m", "second\n\nChange-Id: 0123456789abcdef0123456789abcdef"]);
976 +
    // A branch that edits README, merged back with a conflict-free merge.
977 +
    git(&["checkout", "-q", "-b", "topic", "HEAD~1"]);
978 +
    write(&g, "README.md", b"# Hi\n\nMore.\n");
979 +
    git(&["commit", "-q", "-am", "readme"]);
980 +
    git(&["checkout", "-q", "main"]);
981 +
    git(&["merge", "-q", "--no-ff", "-m", "merge topic", "topic"]);
982 +
    // A branch from the initial commit inserting at the same place as
983 +
    // "second": concurrent in Oxygen too, so the merge commit is a resolution.
984 +
    let initial = git(&["rev-list", "--max-parents=0", "HEAD"]);
985 +
    git(&["checkout", "-q", "-b", "clash", &initial]);
986 +
    write(&g, "src/lib.rs", b"pub fn a() {}\npub fn c() {}\n");
987 +
    git(&["commit", "-q", "-am", "clash"]);
988 +
    git(&["checkout", "-q", "main"]);
989 +
    let _ = std::process::Command::new("git").arg("-C").arg(&g).args(["merge", "-q", "clash"]).output();
990 +
    write(&g, "src/lib.rs", b"pub fn a() {}\npub fn b() {}\npub fn c() {}\n");
991 +
    git(&["add", "."]);
992 +
    git(&["commit", "-q", "-m", "resolve clash"]);
993 +
    git(&["rm", "-q", "logo.png"]);
994 +
    git(&["commit", "-q", "-m", "drop logo"]);
995 +
    let expected_count: usize = git(&["rev-list", "--count", "main"]).parse().unwrap();
996 +
997 +
    let dest = root.join("oxy");
998 +
    let repo = Repo::init(&dest).unwrap().with_home(root.join("home"));
999 +
    let rep = repo.import_git(&g, "main").unwrap();
1000 +
    assert_eq!(rep.commits, expected_count);
1001 +
    assert_eq!(rep.merges, 2);
1002 +
    // The working copy equals the Git tree at main.
1003 +
    assert_eq!(fs::read(dest.join("src/lib.rs")).unwrap(), b"pub fn a() {}\npub fn b() {}\npub fn c() {}\n");
1004 +
    assert_eq!(fs::read(dest.join("README.md")).unwrap(), b"# Hi\n\nMore.\n");
1005 +
    assert!(!dest.join("logo.png").exists());
1006 +
    #[cfg(unix)]
1007 +
    {
1008 +
        use std::os::unix::fs::PermissionsExt;
1009 +
        assert!(fs::metadata(dest.join("run.sh")).unwrap().permissions().mode() & 0o111 != 0);
1010 +
    }
1011 +
    assert!(!repo.snapshot("status").unwrap().changed, "clean after import");
1012 +
    let heads = repo.bookmark_heads().unwrap();
1013 +
    let st = repo.load_state(&heads).unwrap();
1014 +
    assert!(!st.materialize().is_conflicted());
1015 +
    assert_eq!(st.len(), expected_count);
1016 +
    // Messages carry Git trailers; the Change-Id trailer was honoured.
1017 +
    let log = repo.log().unwrap();
1018 +
    let second = log.iter().find(|e| e.change.message.starts_with("second")).unwrap();
1019 +
    assert_eq!(second.change.change_id, ChangeId::from_hex("0123456789abcdef0123456789abcdef").unwrap());
1020 +
    assert!(second.change.message.contains("Git-Author: Grace <[email protected]>"));
1021 +
    let resolve = log.iter().find(|e| e.change.message.starts_with("resolve clash")).unwrap();
1022 +
    assert_eq!(resolve.change.kind, ChangeKind::Resolution, "a conflicting Git merge becomes a resolution");
1023 +
    // Re-importing into another fresh repository gives the same ChangeIds.
1024 +
    let repo2 = Repo::init(&root.join("oxy2")).unwrap().with_home(root.join("home"));
1025 +
    repo2.import_git(&g, "main").unwrap();
1026 +
    let ids: BTreeSet<ChangeId> = repo.log().unwrap().iter().filter(|e| !e.is_working_copy).map(|e| e.change.change_id).collect();
1027 +
    let ids2: BTreeSet<ChangeId> = repo2.log().unwrap().iter().filter(|e| !e.is_working_copy).map(|e| e.change.change_id).collect();
1028 +
    assert_eq!(ids, ids2);
1029 +
    // Round trip back out to Git reproduces the tip tree.
1030 +
    let mirror = root.join("mirror.git");
1031 +
    repo.git_export(&mirror).unwrap();
1032 +
    let out = std::process::Command::new("git").arg("-C").arg(&mirror).args(["show", "main:src/lib.rs"]).output().unwrap();
1033 +
    assert_eq!(String::from_utf8_lossy(&out.stdout), "pub fn a() {}\npub fn b() {}\npub fn c() {}\n");
1034 +
}
modified crates/oxy-core/src/state.rs +1 −1
…
18 unchanged lines
19 19
/// One `Replace` edit: `(change, old blob, new blob)`.
20 20
type ReplaceRecord = (ChangeHash, Option<ObjectRef>, Option<ObjectRef>);
21 21
22 -
#[derive(Default)]
22 +
#[derive(Default, Clone)]
23 23
pub struct State {
24 24
    changes: HashMap<ChangeHash, Change>,
25 25
    tree: TreeGraph,
…
417 unchanged lines
modified src/cli/mod.rs +8 −0
…
168 unchanged lines
169 169
enum GitCmd {
170 170
    /// Export the current bookmark to a Git repository (created bare if absent)
171 171
    Export { git_dir: PathBuf },
172 +
    /// Import a Git branch into a new Oxygen repository at DEST
173 +
    Import {
174 +
        git_dir: PathBuf,
175 +
        dest: PathBuf,
176 +
        #[arg(long, default_value = "main")]
177 +
        branch: String,
178 +
    },
172 179
}
173 180
174 181
#[derive(Subcommand)]
…
52 unchanged lines
227 234
            IdentityCmd::Export { path } => identity::export(path),
228 235
        },
229 236
        Cmd::Git { cmd: GitCmd::Export { git_dir } } => sync::git_export(&git_dir),
237 +
        Cmd::Git { cmd: GitCmd::Import { git_dir, dest, branch } } => sync::git_import(&git_dir, &dest, &branch),
230 238
        Cmd::Policy { cmd } => match cmd {
231 239
            PolicyCmd::Init => identity::policy_init(),
232 240
            PolicyCmd::Show => identity::policy_show(),
…
7 unchanged lines
modified src/cli/sync.rs +18 −0
…
131 unchanged lines
132 132
    );
133 133
    Ok(0)
134 134
}
135 +
136 +
pub fn git_import(git_dir: &std::path::Path, dest: &std::path::Path, branch: &str) -> Result<i32> {
137 +
    let repo = Repo::init(dest)?;
138 +
    let started = std::time::Instant::now();
139 +
    let r = repo.import_git(git_dir, branch)?;
140 +
    println!(
141 +
        "Imported {} ({} merges) from {} {branch} into {} in {:.1?}: {} now has {}.{}",
142 +
        plural(r.commits, "commit"),
143 +
        r.merges,
144 +
        git_dir.display(),
145 +
        repo.root().display(),
146 +
        started.elapsed(),
147 +
        repo.config.bookmark,
148 +
        plural(r.heads, "head"),
149 +
        if r.skipped_submodules > 0 { format!(" Skipped {} submodule entries.", r.skipped_submodules) } else { String::new() }
150 +
    );
151 +
    Ok(0)
152 +
}