OXYGENHUB
# e4b40b3b5af2 oxygen / crates / oxy-core / src / repo / git.rs
35.9 KiB · Rust · last changed Diffs no longer anchor a block of new lines one line early 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
    /// Gitlink entries imported as subrepo nodes with Git pins.
18
    pub submodules: usize,
19
    pub heads: usize,
20
}
21
22
/// A running `git cat-file --batch`, for reading many blobs cheaply.
23
struct CatFile {
24
    child: std::process::Child,
25
    stdin: std::process::ChildStdin,
26
    stdout: std::io::BufReader<std::process::ChildStdout>,
27
}
28
29
impl CatFile {
30
    fn start(git_dir: &Path) -> Result<CatFile> {
31
        let mut child = Command::new("git")
32
            .arg("-C")
33
            .arg(git_dir)
34
            .args(["cat-file", "--batch"])
35
            .stdin(Stdio::piped())
36
            .stdout(Stdio::piped())
37
            .stderr(Stdio::null())
38
            .spawn()
39
            .map_err(|e| RepoError::Msg(format!("running git cat-file: {e}")))?;
40
        let stdin = child.stdin.take().unwrap();
41
        let stdout = std::io::BufReader::new(child.stdout.take().unwrap());
42
        Ok(CatFile {
43
            child,
44
            stdin,
45
            stdout,
46
        })
47
    }
48
49
    fn read(&mut self, sha: &str) -> Result<Vec<u8>> {
50
        use std::io::{BufRead, Read};
51
        writeln!(self.stdin, "{sha}")?;
52
        self.stdin.flush()?;
53
        let mut header = String::new();
54
        self.stdout.read_line(&mut header)?;
55
        let parts: Vec<&str> = header.trim_end().split(' ').collect();
56
        if parts.len() != 3 {
57
            return fail(format!("git cat-file: object {sha} not found"));
58
        }
59
        let size: usize = parts[2]
60
            .parse()
61
            .map_err(|_| RepoError::Msg("git cat-file: bad size".into()))?;
62
        let mut body = vec![0u8; size];
63
        self.stdout.read_exact(&mut body)?;
64
        let mut nl = [0u8; 1];
65
        self.stdout.read_exact(&mut nl)?;
66
        Ok(body)
67
    }
68
}
69
70
impl Drop for CatFile {
71
    fn drop(&mut self) {
72
        let _ = self.child.kill();
73
        let _ = self.child.wait();
74
    }
75
}
76
77
/// One commit as `git log` describes it.
78
struct GitCommit {
79
    sha: String,
80
    parents: Vec<String>,
81
    author_secs: u64,
82
    author: String,
83
    message: String,
84
}
85
86
fn list_commits(git_dir: &Path, branch: &str) -> Result<Vec<GitCommit>> {
87
    let out = Command::new("git")
88
        .arg("-C")
89
        .arg(git_dir)
90
        .args([
91
            "log",
92
            "--reverse",
93
            "--topo-order",
94
            "--format=%H%x00%P%x00%at%x00%an <%ae>%x00%B%x1e",
95
            branch,
96
        ])
97
        .output()
98
        .map_err(|e| RepoError::Msg(format!("running git log: {e}")))?;
99
    if !out.status.success() {
100
        return fail(format!(
101
            "git log {branch} failed: {}",
102
            String::from_utf8_lossy(&out.stderr).trim()
103
        ));
104
    }
105
    let text = String::from_utf8_lossy(&out.stdout);
106
    let mut commits = Vec::new();
107
    for record in text.split('\x1e') {
108
        let record = record.trim_start_matches('\n');
109
        if record.trim().is_empty() {
110
            continue;
111
        }
112
        let f: Vec<&str> = record.splitn(5, '\0').collect();
113
        if f.len() != 5 {
114
            return fail("unexpected git log record");
115
        }
116
        commits.push(GitCommit {
117
            sha: f[0].to_string(),
118
            parents: f[1].split_whitespace().map(str::to_string).collect(),
119
            author_secs: f[2].parse().unwrap_or(0),
120
            author: f[3].to_string(),
121
            message: f[4].trim_end().to_string(),
122
        });
123
    }
124
    Ok(commits)
125
}
126
127
/// `path → (mode, blob sha)` for a commit's tree.
128
fn list_tree(git_dir: &Path, sha: &str) -> Result<BTreeMap<String, (String, String)>> {
129
    let text = git(git_dir, &["ls-tree", "-r", "-z", sha])?;
130
    let mut out = BTreeMap::new();
131
    for entry in text.split('\0') {
132
        if entry.is_empty() {
133
            continue;
134
        }
135
        let Some((meta, path)) = entry.split_once('\t') else {
136
            continue;
137
        };
138
        let m: Vec<&str> = meta.split(' ').collect();
139
        if m.len() == 3 {
140
            out.insert(path.to_string(), (m[0].to_string(), m[2].to_string()));
141
        }
142
    }
143
    Ok(out)
144
}
145
146
/// The ChangeId for a Git commit: its `Change-Id` trailer if it has a
147
/// 32-hex one, else BLAKE3-128 of the commit hash (spec §23).
148
fn change_id_for(sha: &str, message: &str) -> ChangeId {
149
    for line in message.lines().rev() {
150
        if let Some(v) = line.strip_prefix("Change-Id:") {
151
            if let Some(id) = ChangeId::from_hex(v.trim()) {
152
                return id;
153
            }
154
        }
155
    }
156
    let h = blake3::hash(sha.as_bytes());
157
    ChangeId(h.as_bytes()[..16].try_into().unwrap())
158
}
159
160
/// Git commits with a known Oxygen counterpart.
161
struct GitKnown {
162
    /// Imported commit → its change.
163
    imported: HashMap<String, ChangeHash>,
164
    /// Exported commit (or a fetched tip) → the bookmark state it equals.
165
    exported: HashMap<String, ObjectRef>,
166
}
167
168
#[derive(Debug, Clone, Default, PartialEq, Eq)]
169
pub struct GitExportReport {
170
    pub exported: usize,
171
    pub skipped: usize,
172
    /// Subrepo nodes with Oxygen pins, which Git cannot represent.
173
    pub skipped_subrepos: usize,
174
    pub branch: String,
175
    pub heads: usize,
176
}
177
178
fn git(dir: &Path, args: &[&str]) -> Result<String> {
179
    let out = Command::new("git")
180
        .arg("-C")
181
        .arg(dir)
182
        .args(args)
183
        .output()
184
        .map_err(|e| RepoError::Msg(format!("running git: {e}")))?;
185
    if !out.status.success() {
186
        return fail(format!(
187
            "git {} failed: {}",
188
            args.join(" "),
189
            String::from_utf8_lossy(&out.stderr).trim()
190
        ));
191
    }
192
    Ok(String::from_utf8_lossy(&out.stdout).trim().to_string())
193
}
194
195
impl Repo {
196
    fn git_marks_dir(&self, git_dir: &Path) -> Result<PathBuf> {
197
        let canonical = git_dir
198
            .canonicalize()
199
            .unwrap_or_else(|_| git_dir.to_path_buf());
200
        let key = hash_object(ObjectType::Chunk, canonical.to_string_lossy().as_bytes()).short(16);
201
        let dir = self.oxy.join("git").join(key);
202
        fs::create_dir_all(&dir)?;
203
        Ok(dir)
204
    }
205
206
    /// `state ref → Git commit sha` for bookmark states that equal a Git
207
    /// commit's tree (recorded by import and fetch).
208
    fn read_git_states(path: &Path) -> Result<BTreeMap<ObjectRef, String>> {
209
        let mut out = BTreeMap::new();
210
        if let Ok(text) = fs::read_to_string(path) {
211
            for line in text.lines() {
212
                if let Some((h, sha)) = line.split_once(' ') {
213
                    if let Some(h) = ObjectRef::from_hex(h) {
214
                        out.insert(h, sha.trim().to_string());
215
                    }
216
                }
217
            }
218
        }
219
        Ok(out)
220
    }
221
222
    fn record_git_state(&self, git_dir: &Path, state: &ObjectRef, sha: &str) -> Result<()> {
223
        let path = self.git_marks_dir(git_dir)?.join("git-states");
224
        let mut states = Self::read_git_states(&path)?;
225
        states.insert(*state, sha.to_string());
226
        let text: String = states
227
            .iter()
228
            .map(|(h, s)| format!("{} {s}\n", h.to_hex()))
229
            .collect();
230
        write_atomic(&path, text.as_bytes())
231
    }
232
233
    /// The bare mirror this repository keeps for the Git remote `url`.
234
    fn git_mirror(&self, url: &str) -> Result<PathBuf> {
235
        let key = hash_object(ObjectType::Chunk, url.as_bytes()).short(16);
236
        let dir = self.oxy.join("git").join(key).join("mirror.git");
237
        if !dir.join("HEAD").exists() {
238
            fs::create_dir_all(&dir)?;
239
            git(&dir, &["init", "--bare", "-q"])?;
240
        }
241
        Ok(dir)
242
    }
243
244
    /// Git commits already imported into the bookmark, by sha.
245
    pub(crate) fn imported_commits(&self) -> Result<HashMap<String, ChangeHash>> {
246
        let mut map = HashMap::new();
247
        for e in self.log()? {
248
            if e.is_working_copy {
249
                continue;
250
            }
251
            if let Some(sha) = e
252
                .change
253
                .message
254
                .lines()
255
                .rev()
256
                .find_map(|l| l.strip_prefix("Git-Commit: "))
257
            {
258
                map.insert(sha.trim().to_string(), e.hash);
259
            }
260
        }
261
        Ok(map)
262
    }
263
264
    /// Bring the mirror's `branch` up to date with the Git remote `url`.
265
    /// A remote without that branch yet leaves the mirror as it is.
266
    fn git_mirror_fetch(&self, mirror: &Path, url: &str, branch: &str) -> Result<()> {
267
        let spec = format!("+refs/heads/{branch}:refs/heads/{branch}");
268
        match git(mirror, &["fetch", "-q", url, &spec]) {
269
            Ok(_) => Ok(()),
270
            Err(e) => {
271
                let ls = git(mirror, &["ls-remote", "--heads", url, branch]).unwrap_or_default();
272
                if ls.trim().is_empty() {
273
                    Ok(())
274
                } else {
275
                    Err(e)
276
                }
277
            }
278
        }
279
    }
280
281
    /// Fetch `branch` of the Git repository at `url` (a path or a Git URL)
282
    /// through a mirror and import the commits not yet in the bookmark
283
    /// (spec §23). Works on a repository that already has history: the
284
    /// new changes join the bookmark like a pull.
285
    pub fn git_fetch(&self, url: &str, branch: &str) -> Result<GitImportReport> {
286
        let mirror = self.git_mirror(url)?;
287
        self.git_mirror_fetch(&mirror, url, branch)?;
288
        if git(
289
            &mirror,
290
            &[
291
                "rev-parse",
292
                "--verify",
293
                "-q",
294
                &format!("refs/heads/{branch}"),
295
            ],
296
        )
297
        .is_err()
298
        {
299
            return fail(format!("{url} has no branch {branch:?}"));
300
        }
301
        self.import_git_commits(&mirror, branch, &format!("oxy git fetch {url} {branch}"))
302
    }
303
304
    /// Export the bookmark to `branch` of the Git repository at `url`
305
    /// through the mirror and push it. The remote's tip must already have
306
    /// been fetched (`git_fetch`), so the push fast-forwards.
307
    pub fn git_push(&self, url: &str, branch: &str) -> Result<GitExportReport> {
308
        let mirror = self.git_mirror(url)?;
309
        self.git_mirror_fetch(&mirror, url, branch)?;
310
        if let Ok(tip) = git(
311
            &mirror,
312
            &[
313
                "rev-parse",
314
                "--verify",
315
                "-q",
316
                &format!("refs/heads/{branch}"),
317
            ],
318
        ) {
319
            let known = self.git_known(&mirror)?;
320
            if !known.imported.contains_key(&tip) && !known.exported.contains_key(&tip) {
321
                return fail(format!(
322
                    "{url} {branch} has commits not yet fetched; run `oxy git fetch {url}` first"
323
                ));
324
            }
325
        }
326
        let report = self.git_export_as(&mirror, branch)?;
327
        let spec = format!("refs/heads/{branch}:refs/heads/{branch}");
328
        git(&mirror, &["push", "-q", url, &spec])?;
329
        Ok(report)
330
    }
331
332
    fn read_oxy_marks(path: &Path) -> Result<BTreeMap<ObjectRef, u64>> {
333
        let mut out = BTreeMap::new();
334
        if let Ok(text) = fs::read_to_string(path) {
335
            for line in text.lines() {
336
                if let Some((h, m)) = line.split_once(' ') {
337
                    if let (Some(h), Ok(m)) = (ObjectRef::from_hex(h), m.parse()) {
338
                        out.insert(h, m);
339
                    }
340
                }
341
            }
342
        }
343
        Ok(out)
344
    }
345
346
    /// Export the current bookmark to the Git repository at `git_dir`,
347
    /// creating a bare one if nothing is there. Each state the bookmark
348
    /// passed through (per the operation log) becomes one commit whose tree
349
    /// is that state, so the Git branch is a linear history of full trees.
350
    pub fn git_export(&self, git_dir: &Path) -> Result<GitExportReport> {
351
        let bookmark = self.config.bookmark.clone();
352
        self.git_export_as(git_dir, &bookmark)
353
    }
354
355
    /// `git_export` to the Git branch `branch`. A state that came from a
356
    /// Git commit (`oxy git fetch`) is not re-exported: later states chain
357
    /// onto that commit, so a push back to the Git remote fast-forwards.
358
    pub fn git_export_as(&self, git_dir: &Path, branch: &str) -> Result<GitExportReport> {
359
        if !git_dir.join("HEAD").exists() && !git_dir.join(".git").exists() {
360
            fs::create_dir_all(git_dir)?;
361
            git(git_dir, &["init", "--bare", "-q"])?;
362
        }
363
        let marks_dir = self.git_marks_dir(git_dir)?;
364
        let git_marks = marks_dir.join("git-marks");
365
        let oxy_marks_path = marks_dir.join("oxy-marks");
366
        let mut marks = Self::read_oxy_marks(&oxy_marks_path)?;
367
        let git_states = Self::read_git_states(&marks_dir.join("git-states"))?;
368
        let mut next_mark = marks.values().copied().max().unwrap_or(0) + 1;
369
370
        let bookmark = self.config.bookmark.clone();
371
        let mut stream: Vec<u8> = Vec::new();
372
        let mut report = GitExportReport {
373
            branch: branch.to_string(),
374
            heads: self.bookmark_heads()?.len(),
375
            ..Default::default()
376
        };
377
378
        // States along the bookmark, oldest first, skipping unchanged and pruned ones.
379
        let mut ops = self.op_log()?;
380
        ops.reverse();
381
        let mut prev: Option<(ObjectRef, Vec<ChangeHash>)> = None;
382
        // The fast-import parent of the next commit: `:mark` or a commit sha.
383
        let mut last_parent: Option<String> = None;
384
        for (_, op) in &ops {
385
            let Some(state_ref) = op.view.bookmarks.get(&bookmark).copied() else {
386
                continue;
387
            };
388
            if prev.as_ref().is_some_and(|(p, _)| *p == state_ref) {
389
                continue;
390
            }
391
            let Ok(state) = self.store.get_state(&state_ref) else {
392
                continue;
393
            };
394
            if state.heads.is_empty() {
395
                prev = Some((state_ref, vec![]));
396
                continue;
397
            }
398
            if let Some(sha) = git_states.get(&state_ref) {
399
                report.skipped += 1;
400
                last_parent = Some(sha.clone());
401
                prev = Some((state_ref, state.heads.clone()));
402
                continue;
403
            }
404
            if let Some(m) = marks.get(&state_ref) {
405
                report.skipped += 1;
406
                last_parent = Some(format!(":{m}"));
407
                prev = Some((state_ref, state.heads.clone()));
408
                continue;
409
            }
410
            let prev_heads = prev.as_ref().map(|(_, h)| h.clone()).unwrap_or_default();
411
            let added = self
412
                .negotiate(&prev_heads, &state.heads)
413
                .unwrap_or_default();
414
            let loaded = self.load_state(&state.heads)?;
415
            let snapshot = loaded.materialize();
416
            let (author, secs, mut message) = match added.as_slice() {
417
                [h] => {
418
                    let c = self.store.get_change(h)?;
419
                    let mut m = c.message.clone();
420
                    if !m.ends_with('\n') {
421
                        m.push('\n');
422
                    }
423
                    m.push_str(&format!(
424
                        "\nChange-Id: {}\nOxy-Hash: {}\n",
425
                        c.change_id.to_hex(),
426
                        h.to_hex()
427
                    ));
428
                    (self.git_author(&c), c.hlc.wall_ms / 1000, m)
429
                }
430
                [] => (
431
                    "oxy <oxy@localhost>".to_string(),
432
                    op.hlc.wall_ms / 1000,
433
                    format!("oxy: {}\n\n", op.command),
434
                ),
435
                many => {
436
                    let mut m = format!("oxy: {} ({} changes)\n\n", op.command, many.len());
437
                    for h in many {
438
                        let c = self.store.get_change(h)?;
439
                        m.push_str(&format!(
440
                            "- {} {}\n",
441
                            c.change_id.short(8),
442
                            c.message.lines().next().unwrap_or("")
443
                        ));
444
                    }
445
                    m.push_str(&format!(
446
                        "\nOxy-Changes: {}\n",
447
                        many.iter()
448
                            .map(|h| h.to_hex())
449
                            .collect::<Vec<_>>()
450
                            .join(" ")
451
                    ));
452
                    ("oxy <oxy@localhost>".to_string(), op.hlc.wall_ms / 1000, m)
453
                }
454
            };
455
            message.push_str(&format!("Oxy-State: {}\n", state_ref.to_hex()));
456
            if snapshot.is_conflicted() {
457
                message.push_str("Oxy-Conflicted: true\n");
458
            }
459
            let mark = next_mark;
460
            next_mark += 1;
461
            let parents: Vec<String> = last_parent.clone().into_iter().collect();
462
            self.write_commit(
463
                &mut stream,
464
                "refs/oxy/export",
465
                mark,
466
                &author,
467
                secs,
468
                &message,
469
                &parents,
470
                &snapshot,
471
            )?;
472
            marks.insert(state_ref, mark);
473
            last_parent = Some(format!(":{mark}"));
474
            prev = Some((state_ref, state.heads.clone()));
475
            report.exported += 1;
476
        }
477
        if let Some(p) = &last_parent {
478
            stream.extend_from_slice(format!("reset refs/heads/{branch}\nfrom {p}\n\n").as_bytes());
479
        }
480
        // Tags: a tag whose state was exported as a bookmark state points at
481
        // that commit; otherwise its state gets a commit of its own.
482
        for t in self.tags()? {
483
            let target = match (git_states.get(&t.state), marks.get(&t.state)) {
484
                (Some(sha), _) => sha.clone(),
485
                (None, Some(m)) => format!(":{m}"),
486
                (None, None) => {
487
                    let loaded = self.load_state(&t.heads)?;
488
                    let snapshot = loaded.materialize();
489
                    let mark = next_mark;
490
                    next_mark += 1;
491
                    let message = format!(
492
                        "oxy: tag {}\n\n{}Oxy-State: {}\n",
493
                        t.name,
494
                        if t.message.is_empty() {
495
                            String::new()
496
                        } else {
497
                            format!("{}\n\n", t.message)
498
                        },
499
                        t.state.to_hex()
500
                    );
501
                    self.write_commit(
502
                        &mut stream,
503
                        "refs/oxy/export",
504
                        mark,
505
                        "oxy <oxy@localhost>",
506
                        Hlc::now().wall_ms / 1000,
507
                        &message,
508
                        &[],
509
                        &snapshot,
510
                    )?;
511
                    marks.insert(t.state, mark);
512
                    report.exported += 1;
513
                    format!(":{mark}")
514
                }
515
            };
516
            stream.extend_from_slice(
517
                format!("reset refs/tags/{}\nfrom {target}\n\n", t.name).as_bytes(),
518
            );
519
        }
520
        stream.extend_from_slice(b"done\n");
521
522
        let mut cmd = Command::new("git");
523
        // `--force`: the scratch ref may jump between unrelated states.
524
        cmd.arg("-C")
525
            .arg(git_dir)
526
            .args(["fast-import", "--quiet", "--done", "--force"])
527
            .arg(format!("--export-marks={}", git_marks.display()));
528
        if git_marks.exists() {
529
            cmd.arg(format!("--import-marks={}", git_marks.display()));
530
        }
531
        let mut child = cmd
532
            .stdin(Stdio::piped())
533
            .stdout(Stdio::null())
534
            .stderr(Stdio::piped())
535
            .spawn()
536
            .map_err(|e| RepoError::Msg(format!("running git fast-import: {e}")))?;
537
        child.stdin.take().unwrap().write_all(&stream)?;
538
        let out = child.wait_with_output()?;
539
        if !out.status.success() {
540
            return fail(format!(
541
                "git fast-import failed: {}",
542
                String::from_utf8_lossy(&out.stderr).trim()
543
            ));
544
        }
545
        let text: String = marks
546
            .iter()
547
            .map(|(h, m)| format!("{} {m}\n", h.to_hex()))
548
            .collect();
549
        write_atomic(&oxy_marks_path, text.as_bytes())?;
550
        let _ = git(git_dir, &["update-ref", "-d", "refs/oxy/export"]);
551
        report.skipped_subrepos = self.git_export_skipped.replace(0);
552
        Ok(report)
553
    }
554
555
    /// Import branch `branch` of the Git repository at `git_dir` into this
556
    /// (empty) repository's bookmark (spec §23): one change per commit, its
557
    /// edits the difference between the union of its parents' states and its
558
    /// tree, so merge commits become resolutions. Submodules are skipped.
559
    pub fn import_git(&self, git_dir: &Path, branch: &str) -> Result<GitImportReport> {
560
        if !self.bookmark_heads()?.is_empty() {
561
            return fail("git import needs an empty bookmark; import into a fresh repository");
562
        }
563
        self.import_git_commits(
564
            git_dir,
565
            branch,
566
            &format!("oxy git import {} {branch}", git_dir.display()),
567
        )
568
    }
569
570
    /// Import the commits of `branch` that the bookmark does not have yet,
571
    /// then move the bookmark to the union of its heads and theirs.
572
    fn import_git_commits(
573
        &self,
574
        git_dir: &Path,
575
        branch: &str,
576
        command: &str,
577
    ) -> Result<GitImportReport> {
578
        let batch = self.store.begin_batch()?;
579
        let result = self.import_git_commits_batched(git_dir, branch, command);
580
        batch.finish()?;
581
        result
582
    }
583
584
    fn import_git_commits_batched(
585
        &self,
586
        git_dir: &Path,
587
        branch: &str,
588
        command: &str,
589
    ) -> Result<GitImportReport> {
590
        let commits = list_commits(git_dir, branch)?;
591
        let mut cat = CatFile::start(git_dir)?;
592
        let mut report = GitImportReport::default();
593
        let mut known = self.git_known(git_dir)?;
594
        let old_heads = self.bookmark_heads()?;
595
        let mut new_changes: Vec<Change> = Vec::new();
596
        // The state after the most recently imported commit, reused when the
597
        // next commit's first parent is that commit.
598
        let mut last: Option<(String, State)> = None;
599
        let mut blob_cache: HashMap<String, Vec<u8>> = HashMap::new();
600
        let mut cache_bytes: usize = 0;
601
602
        for gc in &commits {
603
            if known.imported.contains_key(&gc.sha) || known.exported.contains_key(&gc.sha) {
604
                continue;
605
            }
606
            // Base: union of every parent's state.
607
            let mut base = match (&gc.parents.first(), &last) {
608
                (Some(p1), Some((sha, st))) if *p1 == sha => st.clone(),
609
                (Some(p1), _) => {
610
                    let mut st = State::new();
611
                    self.add_git_ancestors(&mut st, git_dir, &[p1.as_str()], &known)?;
612
                    st
613
                }
614
                (None, _) => State::new(),
615
            };
616
            if gc.parents.len() > 1 {
617
                report.merges += 1;
618
                let mut args: Vec<String> = gc.parents[1..].to_vec();
619
                args.push(format!("^{}", gc.parents[0]));
620
                let refs: Vec<&str> = args.iter().map(String::as_str).collect();
621
                self.add_git_ancestors(&mut base, git_dir, &refs, &known)?;
622
            }
623
            let base_snapshot = base.materialize();
624
625
            // The commit's tree as in-memory entries.
626
            let mut entries: BTreeMap<String, FsEntry> = BTreeMap::new();
627
            let mut gitlinks: Vec<(String, String)> = Vec::new();
628
            for (path, (mode, sha)) in list_tree(git_dir, &gc.sha)? {
629
                let (kind, exec) = match mode.as_str() {
630
                    "100644" | "100664" => (NodeKind::File, false),
631
                    "100755" => (NodeKind::File, true),
632
                    "120000" => (NodeKind::Symlink, false),
633
                    "160000" => {
634
                        gitlinks.push((path, sha));
635
                        continue;
636
                    }
637
                    _ => continue,
638
                };
639
                let content = match blob_cache.get(&sha) {
640
                    Some(c) => c.clone(),
641
                    None => {
642
                        let c = cat.read(&sha)?;
643
                        if cache_bytes + c.len() > 256 * 1024 * 1024 {
644
                            blob_cache.clear();
645
                            cache_bytes = 0;
646
                        }
647
                        cache_bytes += c.len();
648
                        blob_cache.insert(sha.clone(), c.clone());
649
                        c
650
                    }
651
                };
652
                entries.insert(path, FsEntry::new(kind, exec, content));
653
            }
654
655
            // Submodules (spec §23): gitlinks become subrepo nodes with Git
656
            // pins, and `.gitmodules` also yields `.oxymodules`.
657
            if !gitlinks.is_empty() {
658
                report.submodules += gitlinks.len();
659
                let gm = entries
660
                    .get(".gitmodules")
661
                    .map(|e| {
662
                        crate::subrepo::Modules::from_gitmodules(&String::from_utf8_lossy(
663
                            &e.content,
664
                        ))
665
                    })
666
                    .unwrap_or_default();
667
                let mut modules = entries
668
                    .get(".oxymodules")
669
                    .map(|e| crate::subrepo::Modules::parse(&String::from_utf8_lossy(&e.content)))
670
                    .unwrap_or_default();
671
                for (path, sha) in &gitlinks {
672
                    let m = gm.entries.get(path);
673
                    let branch = m
674
                        .and_then(|m| m.bookmark.clone())
675
                        .unwrap_or_else(|| "HEAD".into());
676
                    entries.insert(
677
                        path.clone(),
678
                        FsEntry::new(
679
                            NodeKind::Subrepo,
680
                            false,
681
                            crate::subrepo::Pin::Git {
682
                                sha: sha.clone(),
683
                                branch,
684
                            }
685
                            .encode(),
686
                        ),
687
                    );
688
                    if let Some(m) = m {
689
                        modules.entries.insert(path.clone(), m.clone());
690
                    }
691
                }
692
                if !modules.entries.is_empty() {
693
                    entries.insert(
694
                        ".oxymodules".into(),
695
                        FsEntry::new(NodeKind::File, false, modules.to_text().into_bytes()),
696
                    );
697
                }
698
            }
699
            let built = self.build_from_entries(&base, &base_snapshot, HashMap::new(), &entries)?;
700
            let mut deps: BTreeSet<ChangeHash> = built.contributors.iter().copied().collect();
701
            let mut message = gc.message.clone();
702
            if !message.ends_with('\n') {
703
                message.push('\n');
704
            }
705
            message.push_str(&format!(
706
                "\nGit-Commit: {}\nGit-Author: {}\n",
707
                gc.sha, gc.author
708
            ));
709
            let mut change = Change {
710
                change_id: change_id_for(&gc.sha, &gc.message),
711
                deps: vec![],
712
                author: ObjectRef::ZERO,
713
                hlc: Hlc {
714
                    wall_ms: gc.author_secs * 1000,
715
                    logical: 0,
716
                },
717
                message,
718
                tree_ops: built.tree_ops,
719
                edits: built.edits,
720
                kind: if built.resolves {
721
                    ChangeKind::Resolution
722
                } else {
723
                    ChangeKind::Normal
724
                },
725
            };
726
            deps.extend(referenced_changes(&change, &base));
727
            change.deps = deps.into_iter().collect();
728
            change.normalize();
729
            let hash = self.store.put_change(&change)?;
730
            new_changes.push(change.clone());
731
            base.add_unchecked(change);
732
            known.imported.insert(gc.sha.clone(), hash);
733
            last = Some((gc.sha.clone(), base));
734
            report.commits += 1;
735
        }
736
737
        // The bookmark: heads of the old state plus every new change.
738
        report.heads = old_heads.len();
739
        if last.is_none() {
740
            return Ok(report);
741
        }
742
        let mut all = self.load_state(&old_heads)?;
743
        for c in new_changes {
744
            all.add_unchecked(c);
745
        }
746
        let mut heads = all.heads();
747
        heads.sort();
748
        report.heads = heads.len();
749
        let (op_ref, op) = self.head_op()?;
750
        let mut view = op.view.clone();
751
        let old_state_ref = view.bookmarks.get(&self.config.bookmark).copied();
752
        let state_ref = self.store.put_state(&StateObject::new(heads.clone()))?;
753
        view.bookmarks
754
            .insert(self.config.bookmark.clone(), state_ref);
755
        self.record_op(&[op_ref], command, view)?;
756
        // The new state equals the tip's tree when the old one was itself a
757
        // Git commit's tree (fetched or exported), or there was none.
758
        let old_known = old_heads.is_empty()
759
            || old_state_ref.is_some_and(|s| known.exported.values().any(|e| *e == s));
760
        if let (Some(tip), true) = (commits.last(), old_known) {
761
            self.record_git_state(git_dir, &state_ref, &tip.sha)?;
762
        }
763
        // The working copy follows when it was clean on the old heads.
764
        if old_heads.is_empty() {
765
            self.checkout(&heads, true, &format!("{command} (checkout)"))?;
766
        } else {
767
            let snap = self.snapshot(command)?;
768
            let clean = snap.wc.tree_ops.is_empty() && snap.wc.edits.is_empty();
769
            let mut base = snap.base.maximal(&snap.wc.deps);
770
            base.sort();
771
            let mut sorted_old = old_heads.clone();
772
            sorted_old.sort();
773
            if clean && base == sorted_old {
774
                self.checkout(&heads, false, &format!("{command} (checkout)"))?;
775
            }
776
        }
777
        Ok(report)
778
    }
779
780
    /// Add to `st` the changes of every Git commit `rev-list args` lists:
781
    /// an imported commit's change, or the whole state an exported commit
782
    /// was made from (which may hold changes Git never saw).
783
    fn add_git_ancestors(
784
        &self,
785
        st: &mut State,
786
        git_dir: &Path,
787
        args: &[&str],
788
        known: &GitKnown,
789
    ) -> Result<()> {
790
        let mut full = vec!["rev-list"];
791
        full.extend(args);
792
        let out = git(git_dir, &full)?;
793
        for sha in out.lines() {
794
            if let Some(h) = known.imported.get(sha) {
795
                st.add_unchecked(self.store.get_change(h)?);
796
            } else if let Some(state_ref) = known.exported.get(sha) {
797
                let heads = self.store.get_state(state_ref)?.heads;
798
                for h in self.negotiate(&[], &heads)? {
799
                    st.add_unchecked(self.store.get_change(&h)?);
800
                }
801
            }
802
        }
803
        Ok(())
804
    }
805
806
    /// Commits this repository can relate to Oxygen history: imported
807
    /// ones by change, exported ones (through `git_dir`'s marks) by state.
808
    fn git_known(&self, git_dir: &Path) -> Result<GitKnown> {
809
        let imported = self.imported_commits()?;
810
        let marks_dir = self.git_marks_dir(git_dir)?;
811
        let oxy_marks = Self::read_oxy_marks(&marks_dir.join("oxy-marks"))?;
812
        let mut sha_of_mark: HashMap<u64, String> = HashMap::new();
813
        if let Ok(text) = fs::read_to_string(marks_dir.join("git-marks")) {
814
            for line in text.lines() {
815
                if let Some((m, sha)) = line.split_once(' ') {
816
                    if let Ok(m) = m.trim_start_matches(':').parse::<u64>() {
817
                        sha_of_mark.insert(m, sha.trim().to_string());
818
                    }
819
                }
820
            }
821
        }
822
        let mut exported = HashMap::new();
823
        for (state_ref, m) in oxy_marks {
824
            if let Some(sha) = sha_of_mark.get(&m) {
825
                exported.insert(sha.clone(), state_ref);
826
            }
827
        }
828
        for (state_ref, sha) in Self::read_git_states(&marks_dir.join("git-states"))? {
829
            exported.entry(sha).or_insert(state_ref);
830
        }
831
        Ok(GitKnown { imported, exported })
832
    }
833
834
    fn git_author(&self, c: &Change) -> String {
835
        match self.store.get_identity(&c.author) {
836
            Ok(id) if !id.name.is_empty() => format!(
837
                "{} <{}>",
838
                id.name.replace(['<', '>', '\n'], " "),
839
                id.email.replace(['<', '>', '\n', ' '], "")
840
            ),
841
            _ => "oxy <oxy@localhost>".to_string(),
842
        }
843
    }
844
845
    #[allow(clippy::too_many_arguments)]
846
    fn write_commit(
847
        &self,
848
        out: &mut Vec<u8>,
849
        reference: &str,
850
        mark: u64,
851
        author: &str,
852
        secs: u64,
853
        message: &str,
854
        parents: &[String],
855
        snapshot: &Snapshot,
856
    ) -> Result<()> {
857
        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());
858
        for (i, p) in parents.iter().enumerate() {
859
            out.extend_from_slice(
860
                format!("{} {p}\n", if i == 0 { "from" } else { "merge" }).as_bytes(),
861
            );
862
        }
863
        out.extend_from_slice(b"deleteall\n");
864
        let has_gitmodules = snapshot.files.contains_key(".gitmodules");
865
        for (path, f) in &snapshot.files {
866
            if path == ".oxymodules" {
867
                // Git reads `.gitmodules`; derive it unless the tree has one.
868
                if !has_gitmodules {
869
                    if let FileContent::Text { bytes, .. } = &f.content {
870
                        let gm = crate::subrepo::Modules::parse(&String::from_utf8_lossy(bytes))
871
                            .to_gitmodules();
872
                        if !gm.is_empty() {
873
                            out.extend_from_slice(
874
                                format!("M 100644 inline .gitmodules\ndata {}\n", gm.len())
875
                                    .as_bytes(),
876
                            );
877
                            out.extend_from_slice(gm.as_bytes());
878
                            out.push(b'\n');
879
                        }
880
                    }
881
                }
882
                continue;
883
            }
884
            if f.kind == NodeKind::Subrepo {
885
                if let FileContent::Opaque { blobs } = &f.content {
886
                    if let Some(b) = blobs.first() {
887
                        match crate::subrepo::Pin::decode(&self.read_blob(b)?)? {
888
                            crate::subrepo::Pin::Git { sha, .. } => {
889
                                out.extend_from_slice(format!("M 160000 {sha} {path}\n").as_bytes())
890
                            }
891
                            crate::subrepo::Pin::Oxy { .. } => self
892
                                .git_export_skipped
893
                                .set(self.git_export_skipped.get() + 1),
894
                        }
895
                    }
896
                }
897
                continue;
898
            }
899
            let (mode, bytes): (&str, Vec<u8>) = match (&f.kind, &f.content) {
900
                (NodeKind::Dir, _) => continue,
901
                (NodeKind::Symlink, FileContent::Opaque { blobs }) => (
902
                    "120000",
903
                    blobs
904
                        .first()
905
                        .map(|b| self.read_blob(b))
906
                        .transpose()?
907
                        .unwrap_or_default(),
908
                ),
909
                (_, FileContent::Text { bytes, .. }) => {
910
                    (if f.mode.exec { "100755" } else { "100644" }, bytes.clone())
911
                }
912
                (_, FileContent::Opaque { blobs }) => (
913
                    if f.mode.exec { "100755" } else { "100644" },
914
                    blobs
915
                        .first()
916
                        .map(|b| self.read_blob(b))
917
                        .transpose()?
918
                        .unwrap_or_default(),
919
                ),
920
                (_, FileContent::Dir) => continue,
921
            };
922
            out.extend_from_slice(
923
                format!("M {mode} inline {path}\ndata {}\n", bytes.len()).as_bytes(),
924
            );
925
            out.extend_from_slice(&bytes);
926
            out.push(b'\n');
927
        }
928
        out.push(b'\n');
929
        Ok(())
930
    }
931
}