OXYGENHUB
# 2b872d074ddb oxygen / crates / oxy-core / src / repo / git.rs
28.8 KiB · Rust · last changed 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) 1 month ago source blame history raw
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 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
/// 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
150
#[derive(Debug, Clone, Default, PartialEq, Eq)]
151
pub struct GitExportReport {
152
    pub exported: usize,
153
    pub skipped: usize,
154
    pub branch: String,
155
    pub heads: usize,
156
}
157
158
fn git(dir: &Path, args: &[&str]) -> Result<String> {
159
    let out = Command::new("git").arg("-C").arg(dir).args(args).output().map_err(|e| RepoError::Msg(format!("running git: {e}")))?;
160
    if !out.status.success() {
161
        return fail(format!("git {} failed: {}", args.join(" "), String::from_utf8_lossy(&out.stderr).trim()));
162
    }
163
    Ok(String::from_utf8_lossy(&out.stdout).trim().to_string())
164
}
165
166
impl Repo {
167
    fn git_marks_dir(&self, git_dir: &Path) -> Result<PathBuf> {
168
        let canonical = git_dir.canonicalize().unwrap_or_else(|_| git_dir.to_path_buf());
169
        let key = hash_object(ObjectType::Chunk, canonical.to_string_lossy().as_bytes()).short(16);
170
        let dir = self.oxy.join("git").join(key);
171
        fs::create_dir_all(&dir)?;
172
        Ok(dir)
173
    }
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
268
    fn read_oxy_marks(path: &Path) -> Result<BTreeMap<ObjectRef, u64>> {
269
        let mut out = BTreeMap::new();
270
        if let Ok(text) = fs::read_to_string(path) {
271
            for line in text.lines() {
272
                if let Some((h, m)) = line.split_once(' ') {
273
                    if let (Some(h), Ok(m)) = (ObjectRef::from_hex(h), m.parse()) {
274
                        out.insert(h, m);
275
                    }
276
                }
277
            }
278
        }
279
        Ok(out)
280
    }
281
282
    /// Export the current bookmark to the Git repository at `git_dir`,
283
    /// creating a bare one if nothing is there. Each state the bookmark
284
    /// passed through (per the operation log) becomes one commit whose tree
285
    /// is that state, so the Git branch is a linear history of full trees.
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> {
295
        if !git_dir.join("HEAD").exists() && !git_dir.join(".git").exists() {
296
            fs::create_dir_all(git_dir)?;
297
            git(git_dir, &["init", "--bare", "-q"])?;
298
        }
299
        let marks_dir = self.git_marks_dir(git_dir)?;
300
        let git_marks = marks_dir.join("git-marks");
301
        let oxy_marks_path = marks_dir.join("oxy-marks");
302
        let mut marks = Self::read_oxy_marks(&oxy_marks_path)?;
303
        let git_states = Self::read_git_states(&marks_dir.join("git-states"))?;
304
        let mut next_mark = marks.values().copied().max().unwrap_or(0) + 1;
305
306
        let bookmark = self.config.bookmark.clone();
307
        let mut stream: Vec<u8> = Vec::new();
308
        let mut report = GitExportReport { branch: branch.to_string(), heads: self.bookmark_heads()?.len(), ..Default::default() };
309
310
        // States along the bookmark, oldest first, skipping unchanged and pruned ones.
311
        let mut ops = self.op_log()?;
312
        ops.reverse();
313
        let mut prev: Option<(ObjectRef, Vec<ChangeHash>)> = None;
314
        // The fast-import parent of the next commit: `:mark` or a commit sha.
315
        let mut last_parent: Option<String> = None;
316
        for (_, op) in &ops {
317
            let Some(state_ref) = op.view.bookmarks.get(&bookmark).copied() else { continue };
318
            if prev.as_ref().is_some_and(|(p, _)| *p == state_ref) {
319
                continue;
320
            }
321
            let Ok(state) = self.store.get_state(&state_ref) else { continue };
322
            if state.heads.is_empty() {
323
                prev = Some((state_ref, vec![]));
324
                continue;
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
            }
332
            if let Some(m) = marks.get(&state_ref) {
333
                report.skipped += 1;
334
                last_parent = Some(format!(":{m}"));
335
                prev = Some((state_ref, state.heads.clone()));
336
                continue;
337
            }
338
            let prev_heads = prev.as_ref().map(|(_, h)| h.clone()).unwrap_or_default();
339
            let added = self.negotiate(&prev_heads, &state.heads).unwrap_or_default();
340
            let loaded = self.load_state(&state.heads)?;
341
            let snapshot = loaded.materialize();
342
            let (author, secs, mut message) = match added.as_slice() {
343
                [h] => {
344
                    let c = self.store.get_change(h)?;
345
                    let mut m = c.message.clone();
346
                    if !m.ends_with('\n') {
347
                        m.push('\n');
348
                    }
349
                    m.push_str(&format!("\nChange-Id: {}\nOxy-Hash: {}\n", c.change_id.to_hex(), h.to_hex()));
350
                    (self.git_author(&c), c.hlc.wall_ms / 1000, m)
351
                }
352
                [] => ("oxy <oxy@localhost>".to_string(), op.hlc.wall_ms / 1000, format!("oxy: {}\n\n", op.command)),
353
                many => {
354
                    let mut m = format!("oxy: {} ({} changes)\n\n", op.command, many.len());
355
                    for h in many {
356
                        let c = self.store.get_change(h)?;
357
                        m.push_str(&format!("- {} {}\n", c.change_id.short(8), c.message.lines().next().unwrap_or("")));
358
                    }
359
                    m.push_str(&format!("\nOxy-Changes: {}\n", many.iter().map(|h| h.to_hex()).collect::<Vec<_>>().join(" ")));
360
                    ("oxy <oxy@localhost>".to_string(), op.hlc.wall_ms / 1000, m)
361
                }
362
            };
363
            message.push_str(&format!("Oxy-State: {}\n", state_ref.to_hex()));
364
            if snapshot.is_conflicted() {
365
                message.push_str("Oxy-Conflicted: true\n");
366
            }
367
            let mark = next_mark;
368
            next_mark += 1;
369
            let parents: Vec<String> = last_parent.clone().into_iter().collect();
370
            self.write_commit(&mut stream, "refs/oxy/export", mark, &author, secs, &message, &parents, &snapshot)?;
371
            marks.insert(state_ref, mark);
372
            last_parent = Some(format!(":{mark}"));
373
            prev = Some((state_ref, state.heads.clone()));
374
            report.exported += 1;
375
        }
376
        if let Some(p) = &last_parent {
377
            stream.extend_from_slice(format!("reset refs/heads/{branch}\nfrom {p}\n\n").as_bytes());
378
        }
379
        // Tags: a tag whose state was exported as a bookmark state points at
380
        // that commit; otherwise its state gets a commit of its own.
381
        for t in self.tags()? {
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) => {
386
                    let loaded = self.load_state(&t.heads)?;
387
                    let snapshot = loaded.materialize();
388
                    let mark = next_mark;
389
                    next_mark += 1;
390
                    let message = format!("oxy: tag {}\n\n{}Oxy-State: {}\n", t.name, if t.message.is_empty() { String::new() } else { format!("{}\n\n", t.message) }, t.state.to_hex());
391
                    self.write_commit(&mut stream, "refs/oxy/export", mark, "oxy <oxy@localhost>", Hlc::now().wall_ms / 1000, &message, &[], &snapshot)?;
392
                    marks.insert(t.state, mark);
393
                    report.exported += 1;
394
                    format!(":{mark}")
395
                }
396
            };
397
            stream.extend_from_slice(format!("reset refs/tags/{}\nfrom {target}\n\n", t.name).as_bytes());
398
        }
399
        stream.extend_from_slice(b"done\n");
400
401
        let mut cmd = Command::new("git");
402
        // `--force`: the scratch ref may jump between unrelated states.
403
        cmd.arg("-C").arg(git_dir).args(["fast-import", "--quiet", "--done", "--force"]).arg(format!("--export-marks={}", git_marks.display()));
404
        if git_marks.exists() {
405
            cmd.arg(format!("--import-marks={}", git_marks.display()));
406
        }
407
        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}")))?;
408
        child.stdin.take().unwrap().write_all(&stream)?;
409
        let out = child.wait_with_output()?;
410
        if !out.status.success() {
411
            return fail(format!("git fast-import failed: {}", String::from_utf8_lossy(&out.stderr).trim()));
412
        }
413
        let text: String = marks.iter().map(|(h, m)| format!("{} {m}\n", h.to_hex())).collect();
414
        write_atomic(&oxy_marks_path, text.as_bytes())?;
415
        let _ = git(git_dir, &["update-ref", "-d", "refs/oxy/export"]);
416
        Ok(report)
417
    }
418
419
    /// Import branch `branch` of the Git repository at `git_dir` into this
420
    /// (empty) repository's bookmark (spec §23): one change per commit, its
421
    /// edits the difference between the union of its parents' states and its
422
    /// tree, so merge commits become resolutions. Submodules are skipped.
423
    pub fn import_git(&self, git_dir: &Path, branch: &str) -> Result<GitImportReport> {
424
        if !self.bookmark_heads()?.is_empty() {
425
            return fail("git import needs an empty bookmark; import into a fresh repository");
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> {
433
        let commits = list_commits(git_dir, branch)?;
434
        let mut cat = CatFile::start(git_dir)?;
435
        let mut report = GitImportReport::default();
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();
439
        // The state after the most recently imported commit, reused when the
440
        // next commit's first parent is that commit.
441
        let mut last: Option<(String, State)> = None;
442
        let mut blob_cache: HashMap<String, Vec<u8>> = HashMap::new();
443
        let mut cache_bytes: usize = 0;
444
445
        for gc in &commits {
446
            if known.imported.contains_key(&gc.sha) || known.exported.contains_key(&gc.sha) {
447
                continue;
448
            }
449
            // Base: union of every parent's state.
450
            let mut base = match (&gc.parents.first(), &last) {
451
                (Some(p1), Some((sha, st))) if *p1 == sha => st.clone(),
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
                }
457
                (None, _) => State::new(),
458
            };
459
            if gc.parents.len() > 1 {
460
                report.merges += 1;
461
                let mut args: Vec<String> = gc.parents[1..].to_vec();
462
                args.push(format!("^{}", gc.parents[0]));
463
                let refs: Vec<&str> = args.iter().map(String::as_str).collect();
464
                self.add_git_ancestors(&mut base, git_dir, &refs, &known)?;
465
            }
466
            let base_snapshot = base.materialize();
467
468
            // The commit's tree as in-memory entries.
469
            let mut entries: BTreeMap<String, FsEntry> = BTreeMap::new();
470
            for (path, (mode, sha)) in list_tree(git_dir, &gc.sha)? {
471
                let (kind, exec) = match mode.as_str() {
472
                    "100644" | "100664" => (NodeKind::File, false),
473
                    "100755" => (NodeKind::File, true),
474
                    "120000" => (NodeKind::Symlink, false),
475
                    "160000" => {
476
                        report.skipped_submodules += 1;
477
                        continue;
478
                    }
479
                    _ => continue,
480
                };
481
                let content = match blob_cache.get(&sha) {
482
                    Some(c) => c.clone(),
483
                    None => {
484
                        let c = cat.read(&sha)?;
485
                        if cache_bytes + c.len() > 256 * 1024 * 1024 {
486
                            blob_cache.clear();
487
                            cache_bytes = 0;
488
                        }
489
                        cache_bytes += c.len();
490
                        blob_cache.insert(sha.clone(), c.clone());
491
                        c
492
                    }
493
                };
494
                entries.insert(path, FsEntry { kind, exec, content });
495
            }
496
497
            let built = self.build_from_entries(&base, &base_snapshot, HashMap::new(), &entries)?;
498
            let mut deps: BTreeSet<ChangeHash> = built.contributors.iter().copied().collect();
499
            let mut message = gc.message.clone();
500
            if !message.ends_with('\n') {
501
                message.push('\n');
502
            }
503
            message.push_str(&format!("\nGit-Commit: {}\nGit-Author: {}\n", gc.sha, gc.author));
504
            let mut change = Change {
505
                change_id: change_id_for(&gc.sha, &gc.message),
506
                deps: vec![],
507
                author: ObjectRef::ZERO,
508
                hlc: Hlc { wall_ms: gc.author_secs * 1000, logical: 0 },
509
                message,
510
                tree_ops: built.tree_ops,
511
                edits: built.edits,
512
                kind: if built.resolves { ChangeKind::Resolution } else { ChangeKind::Normal },
513
            };
514
            deps.extend(referenced_changes(&change, &base));
515
            change.deps = deps.into_iter().collect();
516
            change.normalize();
517
            let hash = self.store.put_change(&change)?;
518
            new_changes.push(change.clone());
519
            base.add_unchecked(change);
520
            known.imported.insert(gc.sha.clone(), hash);
521
            last = Some((gc.sha.clone(), base));
522
            report.commits += 1;
523
        }
524
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();
536
        report.heads = heads.len();
537
        let (op_ref, op) = self.head_op()?;
538
        let mut view = op.view.clone();
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
        }
563
        Ok(report)
564
    }
565
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)?;
573
        for sha in out.lines() {
574
            if let Some(h) = known.imported.get(sha) {
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);
606
            }
607
        }
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 })
612
    }
613
614
    fn git_author(&self, c: &Change) -> String {
615
        match self.store.get_identity(&c.author) {
616
            Ok(id) if !id.name.is_empty() => format!("{} <{}>", id.name.replace(['<', '>', '\n'], " "), id.email.replace(['<', '>', '\n', ' '], "")),
617
            _ => "oxy <oxy@localhost>".to_string(),
618
        }
619
    }
620
621
    #[allow(clippy::too_many_arguments)]
622
    fn write_commit(&self, out: &mut Vec<u8>, reference: &str, mark: u64, author: &str, secs: u64, message: &str, parents: &[String], snapshot: &Snapshot) -> Result<()> {
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());
624
        for (i, p) in parents.iter().enumerate() {
625
            out.extend_from_slice(format!("{} {p}\n", if i == 0 { "from" } else { "merge" }).as_bytes());
626
        }
627
        out.extend_from_slice(b"deleteall\n");
628
        for (path, f) in &snapshot.files {
629
            let (mode, bytes): (&str, Vec<u8>) = match (&f.kind, &f.content) {
630
                (NodeKind::Dir, _) => continue,
631
                (NodeKind::Symlink, FileContent::Opaque { blobs }) => ("120000", blobs.first().map(|b| self.read_blob(b)).transpose()?.unwrap_or_default()),
632
                (_, FileContent::Text { bytes, .. }) => (if f.mode.exec { "100755" } else { "100644" }, bytes.clone()),
633
                (_, FileContent::Opaque { blobs }) => (if f.mode.exec { "100755" } else { "100644" }, blobs.first().map(|b| self.read_blob(b)).transpose()?.unwrap_or_default()),
634
                (_, FileContent::Dir) => continue,
635
            };
636
            out.extend_from_slice(format!("M {mode} inline {path}\ndata {}\n", bytes.len()).as_bytes());
637
            out.extend_from_slice(&bytes);
638
            out.push(b'\n');
639
        }
640
        out.push(b'\n');
641
        Ok(())
642
    }
643
}