OXYGENHUB
# 7b9809e67c3d oxygen / crates / oxy-core / src / repo / git.rs
29.0 KiB · Rust · last changed Snapshot: the manifest is a stat cache; unchanged opaque files are not read and contribute their recorded blob, with a two-second racy-stat window (status on 1 GiB 2.4 s to 4 ms; spec 0.6s) 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 batch = self.store.begin_batch()?;
434
        let result = self.import_git_commits_batched(git_dir, branch, command);
435
        batch.finish()?;
436
        result
437
    }
438
439
    fn import_git_commits_batched(&self, git_dir: &Path, branch: &str, command: &str) -> Result<GitImportReport> {
440
        let commits = list_commits(git_dir, branch)?;
441
        let mut cat = CatFile::start(git_dir)?;
442
        let mut report = GitImportReport::default();
443
        let mut known = self.git_known(git_dir)?;
444
        let old_heads = self.bookmark_heads()?;
445
        let mut new_changes: Vec<Change> = Vec::new();
446
        // The state after the most recently imported commit, reused when the
447
        // next commit's first parent is that commit.
448
        let mut last: Option<(String, State)> = None;
449
        let mut blob_cache: HashMap<String, Vec<u8>> = HashMap::new();
450
        let mut cache_bytes: usize = 0;
451
452
        for gc in &commits {
453
            if known.imported.contains_key(&gc.sha) || known.exported.contains_key(&gc.sha) {
454
                continue;
455
            }
456
            // Base: union of every parent's state.
457
            let mut base = match (&gc.parents.first(), &last) {
458
                (Some(p1), Some((sha, st))) if *p1 == sha => st.clone(),
459
                (Some(p1), _) => {
460
                    let mut st = State::new();
461
                    self.add_git_ancestors(&mut st, git_dir, &[p1.as_str()], &known)?;
462
                    st
463
                }
464
                (None, _) => State::new(),
465
            };
466
            if gc.parents.len() > 1 {
467
                report.merges += 1;
468
                let mut args: Vec<String> = gc.parents[1..].to_vec();
469
                args.push(format!("^{}", gc.parents[0]));
470
                let refs: Vec<&str> = args.iter().map(String::as_str).collect();
471
                self.add_git_ancestors(&mut base, git_dir, &refs, &known)?;
472
            }
473
            let base_snapshot = base.materialize();
474
475
            // The commit's tree as in-memory entries.
476
            let mut entries: BTreeMap<String, FsEntry> = BTreeMap::new();
477
            for (path, (mode, sha)) in list_tree(git_dir, &gc.sha)? {
478
                let (kind, exec) = match mode.as_str() {
479
                    "100644" | "100664" => (NodeKind::File, false),
480
                    "100755" => (NodeKind::File, true),
481
                    "120000" => (NodeKind::Symlink, false),
482
                    "160000" => {
483
                        report.skipped_submodules += 1;
484
                        continue;
485
                    }
486
                    _ => continue,
487
                };
488
                let content = match blob_cache.get(&sha) {
489
                    Some(c) => c.clone(),
490
                    None => {
491
                        let c = cat.read(&sha)?;
492
                        if cache_bytes + c.len() > 256 * 1024 * 1024 {
493
                            blob_cache.clear();
494
                            cache_bytes = 0;
495
                        }
496
                        cache_bytes += c.len();
497
                        blob_cache.insert(sha.clone(), c.clone());
498
                        c
499
                    }
500
                };
501
                entries.insert(path, FsEntry::new(kind, exec, content));
502
            }
503
504
            let built = self.build_from_entries(&base, &base_snapshot, HashMap::new(), &entries)?;
505
            let mut deps: BTreeSet<ChangeHash> = built.contributors.iter().copied().collect();
506
            let mut message = gc.message.clone();
507
            if !message.ends_with('\n') {
508
                message.push('\n');
509
            }
510
            message.push_str(&format!("\nGit-Commit: {}\nGit-Author: {}\n", gc.sha, gc.author));
511
            let mut change = Change {
512
                change_id: change_id_for(&gc.sha, &gc.message),
513
                deps: vec![],
514
                author: ObjectRef::ZERO,
515
                hlc: Hlc { wall_ms: gc.author_secs * 1000, logical: 0 },
516
                message,
517
                tree_ops: built.tree_ops,
518
                edits: built.edits,
519
                kind: if built.resolves { ChangeKind::Resolution } else { ChangeKind::Normal },
520
            };
521
            deps.extend(referenced_changes(&change, &base));
522
            change.deps = deps.into_iter().collect();
523
            change.normalize();
524
            let hash = self.store.put_change(&change)?;
525
            new_changes.push(change.clone());
526
            base.add_unchecked(change);
527
            known.imported.insert(gc.sha.clone(), hash);
528
            last = Some((gc.sha.clone(), base));
529
            report.commits += 1;
530
        }
531
532
        // The bookmark: heads of the old state plus every new change.
533
        report.heads = old_heads.len();
534
        if last.is_none() {
535
            return Ok(report);
536
        }
537
        let mut all = self.load_state(&old_heads)?;
538
        for c in new_changes {
539
            all.add_unchecked(c);
540
        }
541
        let mut heads = all.heads();
542
        heads.sort();
543
        report.heads = heads.len();
544
        let (op_ref, op) = self.head_op()?;
545
        let mut view = op.view.clone();
546
        let old_state_ref = view.bookmarks.get(&self.config.bookmark).copied();
547
        let state_ref = self.store.put_state(&StateObject::new(heads.clone()))?;
548
        view.bookmarks.insert(self.config.bookmark.clone(), state_ref);
549
        self.record_op(&[op_ref], command, view)?;
550
        // The new state equals the tip's tree when the old one was itself a
551
        // Git commit's tree (fetched or exported), or there was none.
552
        let old_known = old_heads.is_empty() || old_state_ref.is_some_and(|s| known.exported.values().any(|e| *e == s));
553
        if let (Some(tip), true) = (commits.last(), old_known) {
554
            self.record_git_state(git_dir, &state_ref, &tip.sha)?;
555
        }
556
        // The working copy follows when it was clean on the old heads.
557
        if old_heads.is_empty() {
558
            self.checkout(&heads, true, &format!("{command} (checkout)"))?;
559
        } else {
560
            let snap = self.snapshot(command)?;
561
            let clean = snap.wc.tree_ops.is_empty() && snap.wc.edits.is_empty();
562
            let mut base = snap.base.maximal(&snap.wc.deps);
563
            base.sort();
564
            let mut sorted_old = old_heads.clone();
565
            sorted_old.sort();
566
            if clean && base == sorted_old {
567
                self.checkout(&heads, false, &format!("{command} (checkout)"))?;
568
            }
569
        }
570
        Ok(report)
571
    }
572
573
    /// Add to `st` the changes of every Git commit `rev-list args` lists:
574
    /// an imported commit's change, or the whole state an exported commit
575
    /// was made from (which may hold changes Git never saw).
576
    fn add_git_ancestors(&self, st: &mut State, git_dir: &Path, args: &[&str], known: &GitKnown) -> Result<()> {
577
        let mut full = vec!["rev-list"];
578
        full.extend(args);
579
        let out = git(git_dir, &full)?;
580
        for sha in out.lines() {
581
            if let Some(h) = known.imported.get(sha) {
582
                st.add_unchecked(self.store.get_change(h)?);
583
            } else if let Some(state_ref) = known.exported.get(sha) {
584
                let heads = self.store.get_state(state_ref)?.heads;
585
                for h in self.negotiate(&[], &heads)? {
586
                    st.add_unchecked(self.store.get_change(&h)?);
587
                }
588
            }
589
        }
590
        Ok(())
591
    }
592
593
    /// Commits this repository can relate to Oxygen history: imported
594
    /// ones by change, exported ones (through `git_dir`'s marks) by state.
595
    fn git_known(&self, git_dir: &Path) -> Result<GitKnown> {
596
        let imported = self.imported_commits()?;
597
        let marks_dir = self.git_marks_dir(git_dir)?;
598
        let oxy_marks = Self::read_oxy_marks(&marks_dir.join("oxy-marks"))?;
599
        let mut sha_of_mark: HashMap<u64, String> = HashMap::new();
600
        if let Ok(text) = fs::read_to_string(marks_dir.join("git-marks")) {
601
            for line in text.lines() {
602
                if let Some((m, sha)) = line.split_once(' ') {
603
                    if let Ok(m) = m.trim_start_matches(':').parse::<u64>() {
604
                        sha_of_mark.insert(m, sha.trim().to_string());
605
                    }
606
                }
607
            }
608
        }
609
        let mut exported = HashMap::new();
610
        for (state_ref, m) in oxy_marks {
611
            if let Some(sha) = sha_of_mark.get(&m) {
612
                exported.insert(sha.clone(), state_ref);
613
            }
614
        }
615
        for (state_ref, sha) in Self::read_git_states(&marks_dir.join("git-states"))? {
616
            exported.entry(sha).or_insert(state_ref);
617
        }
618
        Ok(GitKnown { imported, exported })
619
    }
620
621
    fn git_author(&self, c: &Change) -> String {
622
        match self.store.get_identity(&c.author) {
623
            Ok(id) if !id.name.is_empty() => format!("{} <{}>", id.name.replace(['<', '>', '\n'], " "), id.email.replace(['<', '>', '\n', ' '], "")),
624
            _ => "oxy <oxy@localhost>".to_string(),
625
        }
626
    }
627
628
    #[allow(clippy::too_many_arguments)]
629
    fn write_commit(&self, out: &mut Vec<u8>, reference: &str, mark: u64, author: &str, secs: u64, message: &str, parents: &[String], snapshot: &Snapshot) -> Result<()> {
630
        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());
631
        for (i, p) in parents.iter().enumerate() {
632
            out.extend_from_slice(format!("{} {p}\n", if i == 0 { "from" } else { "merge" }).as_bytes());
633
        }
634
        out.extend_from_slice(b"deleteall\n");
635
        for (path, f) in &snapshot.files {
636
            let (mode, bytes): (&str, Vec<u8>) = match (&f.kind, &f.content) {
637
                (NodeKind::Dir, _) => continue,
638
                (NodeKind::Symlink, FileContent::Opaque { blobs }) => ("120000", blobs.first().map(|b| self.read_blob(b)).transpose()?.unwrap_or_default()),
639
                (_, FileContent::Text { bytes, .. }) => (if f.mode.exec { "100755" } else { "100644" }, bytes.clone()),
640
                (_, FileContent::Opaque { blobs }) => (if f.mode.exec { "100755" } else { "100644" }, blobs.first().map(|b| self.read_blob(b)).transpose()?.unwrap_or_default()),
641
                (_, FileContent::Dir) => continue,
642
            };
643
            out.extend_from_slice(format!("M {mode} inline {path}\ndata {}\n", bytes.len()).as_bytes());
644
            out.extend_from_slice(&bytes);
645
            out.push(b'\n');
646
        }
647
        out.push(b'\n');
648
        Ok(())
649
    }
650
}