OXYGENHUB
# 58dfe0e134eb oxygen / crates / oxy-core / src / repo / git.rs
37.1 KiB · Rust · last changed oxy git import says where it is 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
    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(
302
            &mirror,
303
            branch,
304
            &format!("oxy git fetch {url} {branch}"),
305
            &mut |_| {},
306
        )
307
    }
308
309
    /// Export the bookmark to `branch` of the Git repository at `url`
310
    /// through the mirror and push it. The remote's tip must already have
311
    /// been fetched (`git_fetch`), so the push fast-forwards.
312
    pub fn git_push(&self, url: &str, branch: &str) -> Result<GitExportReport> {
313
        let mirror = self.git_mirror(url)?;
314
        self.git_mirror_fetch(&mirror, url, branch)?;
315
        if let Ok(tip) = git(
316
            &mirror,
317
            &[
318
                "rev-parse",
319
                "--verify",
320
                "-q",
321
                &format!("refs/heads/{branch}"),
322
            ],
323
        ) {
324
            let known = self.git_known(&mirror)?;
325
            if !known.imported.contains_key(&tip) && !known.exported.contains_key(&tip) {
326
                return fail(format!(
327
                    "{url} {branch} has commits not yet fetched; run `oxy git fetch {url}` first"
328
                ));
329
            }
330
        }
331
        let report = self.git_export_as(&mirror, branch)?;
332
        let spec = format!("refs/heads/{branch}:refs/heads/{branch}");
333
        git(&mirror, &["push", "-q", url, &spec])?;
334
        Ok(report)
335
    }
336
337
    fn read_oxy_marks(path: &Path) -> Result<BTreeMap<ObjectRef, u64>> {
338
        let mut out = BTreeMap::new();
339
        if let Ok(text) = fs::read_to_string(path) {
340
            for line in text.lines() {
341
                if let Some((h, m)) = line.split_once(' ') {
342
                    if let (Some(h), Ok(m)) = (ObjectRef::from_hex(h), m.parse()) {
343
                        out.insert(h, m);
344
                    }
345
                }
346
            }
347
        }
348
        Ok(out)
349
    }
350
351
    /// Export the current bookmark to the Git repository at `git_dir`,
352
    /// creating a bare one if nothing is there. Each state the bookmark
353
    /// passed through (per the operation log) becomes one commit whose tree
354
    /// is that state, so the Git branch is a linear history of full trees.
355
    pub fn git_export(&self, git_dir: &Path) -> Result<GitExportReport> {
356
        let bookmark = self.config.bookmark.clone();
357
        self.git_export_as(git_dir, &bookmark)
358
    }
359
360
    /// `git_export` to the Git branch `branch`. A state that came from a
361
    /// Git commit (`oxy git fetch`) is not re-exported: later states chain
362
    /// onto that commit, so a push back to the Git remote fast-forwards.
363
    pub fn git_export_as(&self, git_dir: &Path, branch: &str) -> Result<GitExportReport> {
364
        if !git_dir.join("HEAD").exists() && !git_dir.join(".git").exists() {
365
            fs::create_dir_all(git_dir)?;
366
            git(git_dir, &["init", "--bare", "-q"])?;
367
        }
368
        let marks_dir = self.git_marks_dir(git_dir)?;
369
        let git_marks = marks_dir.join("git-marks");
370
        let oxy_marks_path = marks_dir.join("oxy-marks");
371
        let mut marks = Self::read_oxy_marks(&oxy_marks_path)?;
372
        let git_states = Self::read_git_states(&marks_dir.join("git-states"))?;
373
        let mut next_mark = marks.values().copied().max().unwrap_or(0) + 1;
374
375
        let bookmark = self.config.bookmark.clone();
376
        let mut stream: Vec<u8> = Vec::new();
377
        let mut report = GitExportReport {
378
            branch: branch.to_string(),
379
            heads: self.bookmark_heads()?.len(),
380
            ..Default::default()
381
        };
382
383
        // States along the bookmark, oldest first, skipping unchanged and pruned ones.
384
        let mut ops = self.op_log()?;
385
        ops.reverse();
386
        let mut prev: Option<(ObjectRef, Vec<ChangeHash>)> = None;
387
        // The fast-import parent of the next commit: `:mark` or a commit sha.
388
        let mut last_parent: Option<String> = None;
389
        for (_, op) in &ops {
390
            let Some(state_ref) = op.view.bookmarks.get(&bookmark).copied() else {
391
                continue;
392
            };
393
            if prev.as_ref().is_some_and(|(p, _)| *p == state_ref) {
394
                continue;
395
            }
396
            let Ok(state) = self.store.get_state(&state_ref) else {
397
                continue;
398
            };
399
            if state.heads.is_empty() {
400
                prev = Some((state_ref, vec![]));
401
                continue;
402
            }
403
            if let Some(sha) = git_states.get(&state_ref) {
404
                report.skipped += 1;
405
                last_parent = Some(sha.clone());
406
                prev = Some((state_ref, state.heads.clone()));
407
                continue;
408
            }
409
            if let Some(m) = marks.get(&state_ref) {
410
                report.skipped += 1;
411
                last_parent = Some(format!(":{m}"));
412
                prev = Some((state_ref, state.heads.clone()));
413
                continue;
414
            }
415
            let prev_heads = prev.as_ref().map(|(_, h)| h.clone()).unwrap_or_default();
416
            let added = self
417
                .negotiate(&prev_heads, &state.heads)
418
                .unwrap_or_default();
419
            let loaded = self.load_state(&state.heads)?;
420
            let snapshot = loaded.materialize();
421
            let (author, secs, mut message) = match added.as_slice() {
422
                [h] => {
423
                    let c = self.store.get_change(h)?;
424
                    let mut m = c.message.clone();
425
                    if !m.ends_with('\n') {
426
                        m.push('\n');
427
                    }
428
                    m.push_str(&format!(
429
                        "\nChange-Id: {}\nOxy-Hash: {}\n",
430
                        c.change_id.to_hex(),
431
                        h.to_hex()
432
                    ));
433
                    (self.git_author(&c), c.hlc.wall_ms / 1000, m)
434
                }
435
                [] => (
436
                    "oxy <oxy@localhost>".to_string(),
437
                    op.hlc.wall_ms / 1000,
438
                    format!("oxy: {}\n\n", op.command),
439
                ),
440
                many => {
441
                    let mut m = format!("oxy: {} ({} changes)\n\n", op.command, many.len());
442
                    for h in many {
443
                        let c = self.store.get_change(h)?;
444
                        m.push_str(&format!(
445
                            "- {} {}\n",
446
                            c.change_id.short(8),
447
                            c.message.lines().next().unwrap_or("")
448
                        ));
449
                    }
450
                    m.push_str(&format!(
451
                        "\nOxy-Changes: {}\n",
452
                        many.iter()
453
                            .map(|h| h.to_hex())
454
                            .collect::<Vec<_>>()
455
                            .join(" ")
456
                    ));
457
                    ("oxy <oxy@localhost>".to_string(), op.hlc.wall_ms / 1000, m)
458
                }
459
            };
460
            message.push_str(&format!("Oxy-State: {}\n", state_ref.to_hex()));
461
            if snapshot.is_conflicted() {
462
                message.push_str("Oxy-Conflicted: true\n");
463
            }
464
            let mark = next_mark;
465
            next_mark += 1;
466
            let parents: Vec<String> = last_parent.clone().into_iter().collect();
467
            self.write_commit(
468
                &mut stream,
469
                "refs/oxy/export",
470
                mark,
471
                &author,
472
                secs,
473
                &message,
474
                &parents,
475
                &snapshot,
476
            )?;
477
            marks.insert(state_ref, mark);
478
            last_parent = Some(format!(":{mark}"));
479
            prev = Some((state_ref, state.heads.clone()));
480
            report.exported += 1;
481
        }
482
        if let Some(p) = &last_parent {
483
            stream.extend_from_slice(format!("reset refs/heads/{branch}\nfrom {p}\n\n").as_bytes());
484
        }
485
        // Tags: a tag whose state was exported as a bookmark state points at
486
        // that commit; otherwise its state gets a commit of its own.
487
        for t in self.tags()? {
488
            let target = match (git_states.get(&t.state), marks.get(&t.state)) {
489
                (Some(sha), _) => sha.clone(),
490
                (None, Some(m)) => format!(":{m}"),
491
                (None, None) => {
492
                    let loaded = self.load_state(&t.heads)?;
493
                    let snapshot = loaded.materialize();
494
                    let mark = next_mark;
495
                    next_mark += 1;
496
                    let message = format!(
497
                        "oxy: tag {}\n\n{}Oxy-State: {}\n",
498
                        t.name,
499
                        if t.message.is_empty() {
500
                            String::new()
501
                        } else {
502
                            format!("{}\n\n", t.message)
503
                        },
504
                        t.state.to_hex()
505
                    );
506
                    self.write_commit(
507
                        &mut stream,
508
                        "refs/oxy/export",
509
                        mark,
510
                        "oxy <oxy@localhost>",
511
                        Hlc::now().wall_ms / 1000,
512
                        &message,
513
                        &[],
514
                        &snapshot,
515
                    )?;
516
                    marks.insert(t.state, mark);
517
                    report.exported += 1;
518
                    format!(":{mark}")
519
                }
520
            };
521
            stream.extend_from_slice(
522
                format!("reset refs/tags/{}\nfrom {target}\n\n", t.name).as_bytes(),
523
            );
524
        }
525
        stream.extend_from_slice(b"done\n");
526
527
        let mut cmd = Command::new("git");
528
        // `--force`: the scratch ref may jump between unrelated states.
529
        cmd.arg("-C")
530
            .arg(git_dir)
531
            .args(["fast-import", "--quiet", "--done", "--force"])
532
            .arg(format!("--export-marks={}", git_marks.display()));
533
        if git_marks.exists() {
534
            cmd.arg(format!("--import-marks={}", git_marks.display()));
535
        }
536
        let mut child = cmd
537
            .stdin(Stdio::piped())
538
            .stdout(Stdio::null())
539
            .stderr(Stdio::piped())
540
            .spawn()
541
            .map_err(|e| RepoError::Msg(format!("running git fast-import: {e}")))?;
542
        child.stdin.take().unwrap().write_all(&stream)?;
543
        let out = child.wait_with_output()?;
544
        if !out.status.success() {
545
            return fail(format!(
546
                "git fast-import failed: {}",
547
                String::from_utf8_lossy(&out.stderr).trim()
548
            ));
549
        }
550
        let text: String = marks
551
            .iter()
552
            .map(|(h, m)| format!("{} {m}\n", h.to_hex()))
553
            .collect();
554
        write_atomic(&oxy_marks_path, text.as_bytes())?;
555
        let _ = git(git_dir, &["update-ref", "-d", "refs/oxy/export"]);
556
        report.skipped_subrepos = self.git_export_skipped.replace(0);
557
        Ok(report)
558
    }
559
560
    /// Import branch `branch` of the Git repository at `git_dir` into this
561
    /// (empty) repository's bookmark (spec §23): one change per commit, its
562
    /// edits the difference between the union of its parents' states and its
563
    /// tree, so merge commits become resolutions. Submodules are skipped.
564
    pub fn import_git(&self, git_dir: &Path, branch: &str) -> Result<GitImportReport> {
565
        self.import_git_with(git_dir, branch, &mut |_| {})
566
    }
567
568
    /// `import_git`, telling `progress` where it is every few hundred
569
    /// commits: the count so far, the merges, and the commit in hand, so a
570
    /// long import is never silent and a run that dies says where.
571
    pub fn import_git_with(
572
        &self,
573
        git_dir: &Path,
574
        branch: &str,
575
        progress: crate::store::Progress<'_>,
576
    ) -> Result<GitImportReport> {
577
        if !self.bookmark_heads()?.is_empty() {
578
            return fail("git import needs an empty bookmark; import into a fresh repository");
579
        }
580
        self.import_git_commits(
581
            git_dir,
582
            branch,
583
            &format!("oxy git import {} {branch}", git_dir.display()),
584
            progress,
585
        )
586
    }
587
588
    /// Import the commits of `branch` that the bookmark does not have yet,
589
    /// then move the bookmark to the union of its heads and theirs.
590
    fn import_git_commits(
591
        &self,
592
        git_dir: &Path,
593
        branch: &str,
594
        command: &str,
595
        progress: crate::store::Progress<'_>,
596
    ) -> Result<GitImportReport> {
597
        let batch = self.store.begin_batch()?;
598
        let result = self.import_git_commits_batched(git_dir, branch, command, progress);
599
        batch.finish()?;
600
        result
601
    }
602
603
    fn import_git_commits_batched(
604
        &self,
605
        git_dir: &Path,
606
        branch: &str,
607
        command: &str,
608
        progress: crate::store::Progress<'_>,
609
    ) -> Result<GitImportReport> {
610
        let commits = list_commits(git_dir, branch)?;
611
        let mut cat = CatFile::start(git_dir)?;
612
        let mut report = GitImportReport::default();
613
        let mut known = self.git_known(git_dir)?;
614
        let old_heads = self.bookmark_heads()?;
615
        let mut new_changes: Vec<Change> = Vec::new();
616
        // The state after the most recently imported commit, reused when the
617
        // next commit's first parent is that commit.
618
        let mut last: Option<(String, State)> = None;
619
        let mut blob_cache: HashMap<String, Vec<u8>> = HashMap::new();
620
        let mut cache_bytes: usize = 0;
621
622
        let total = commits.len();
623
        for (index, gc) in commits.iter().enumerate() {
624
            if known.imported.contains_key(&gc.sha) || known.exported.contains_key(&gc.sha) {
625
                continue;
626
            }
627
            if index % 500 == 0 {
628
                progress(&format!(
629
                    "{index} of {total} commits, {} merges; at {} {}",
630
                    report.merges,
631
                    &gc.sha[..12.min(gc.sha.len())],
632
                    gc.message
633
                        .lines()
634
                        .next()
635
                        .unwrap_or("")
636
                        .chars()
637
                        .take(60)
638
                        .collect::<String>()
639
                ));
640
            }
641
            // Base: union of every parent's state.
642
            let mut base = match (&gc.parents.first(), &last) {
643
                (Some(p1), Some((sha, st))) if *p1 == sha => st.clone(),
644
                (Some(p1), _) => {
645
                    let mut st = State::new();
646
                    self.add_git_ancestors(&mut st, git_dir, &[p1.as_str()], &known)?;
647
                    st
648
                }
649
                (None, _) => State::new(),
650
            };
651
            if gc.parents.len() > 1 {
652
                report.merges += 1;
653
                let mut args: Vec<String> = gc.parents[1..].to_vec();
654
                args.push(format!("^{}", gc.parents[0]));
655
                let refs: Vec<&str> = args.iter().map(String::as_str).collect();
656
                self.add_git_ancestors(&mut base, git_dir, &refs, &known)?;
657
            }
658
            let base_snapshot = base.materialize();
659
660
            // The commit's tree as in-memory entries.
661
            let mut entries: BTreeMap<String, FsEntry> = BTreeMap::new();
662
            let mut gitlinks: Vec<(String, String)> = Vec::new();
663
            for (path, (mode, sha)) in list_tree(git_dir, &gc.sha)? {
664
                let (kind, exec) = match mode.as_str() {
665
                    "100644" | "100664" => (NodeKind::File, false),
666
                    "100755" => (NodeKind::File, true),
667
                    "120000" => (NodeKind::Symlink, false),
668
                    "160000" => {
669
                        gitlinks.push((path, sha));
670
                        continue;
671
                    }
672
                    _ => continue,
673
                };
674
                let content = match blob_cache.get(&sha) {
675
                    Some(c) => c.clone(),
676
                    None => {
677
                        let c = cat.read(&sha)?;
678
                        if cache_bytes + c.len() > 256 * 1024 * 1024 {
679
                            blob_cache.clear();
680
                            cache_bytes = 0;
681
                        }
682
                        cache_bytes += c.len();
683
                        blob_cache.insert(sha.clone(), c.clone());
684
                        c
685
                    }
686
                };
687
                entries.insert(path, FsEntry::new(kind, exec, content));
688
            }
689
690
            // Submodules (spec §23): gitlinks become subrepo nodes with Git
691
            // pins, and `.gitmodules` also yields `.oxymodules`.
692
            if !gitlinks.is_empty() {
693
                report.submodules += gitlinks.len();
694
                let gm = entries
695
                    .get(".gitmodules")
696
                    .map(|e| {
697
                        crate::subrepo::Modules::from_gitmodules(&String::from_utf8_lossy(
698
                            &e.content,
699
                        ))
700
                    })
701
                    .unwrap_or_default();
702
                let mut modules = entries
703
                    .get(".oxymodules")
704
                    .map(|e| crate::subrepo::Modules::parse(&String::from_utf8_lossy(&e.content)))
705
                    .unwrap_or_default();
706
                for (path, sha) in &gitlinks {
707
                    let m = gm.entries.get(path);
708
                    let branch = m
709
                        .and_then(|m| m.bookmark.clone())
710
                        .unwrap_or_else(|| "HEAD".into());
711
                    entries.insert(
712
                        path.clone(),
713
                        FsEntry::new(
714
                            NodeKind::Subrepo,
715
                            false,
716
                            crate::subrepo::Pin::Git {
717
                                sha: sha.clone(),
718
                                branch,
719
                            }
720
                            .encode(),
721
                        ),
722
                    );
723
                    if let Some(m) = m {
724
                        modules.entries.insert(path.clone(), m.clone());
725
                    }
726
                }
727
                if !modules.entries.is_empty() {
728
                    entries.insert(
729
                        ".oxymodules".into(),
730
                        FsEntry::new(NodeKind::File, false, modules.to_text().into_bytes()),
731
                    );
732
                }
733
            }
734
            let built = self.build_from_entries(&base, &base_snapshot, HashMap::new(), &entries)?;
735
            let mut deps: BTreeSet<ChangeHash> = built.contributors.iter().copied().collect();
736
            let mut message = gc.message.clone();
737
            if !message.ends_with('\n') {
738
                message.push('\n');
739
            }
740
            message.push_str(&format!(
741
                "\nGit-Commit: {}\nGit-Author: {}\n",
742
                gc.sha, gc.author
743
            ));
744
            let mut change = Change {
745
                change_id: change_id_for(&gc.sha, &gc.message),
746
                deps: vec![],
747
                author: ObjectRef::ZERO,
748
                hlc: Hlc {
749
                    wall_ms: gc.author_secs * 1000,
750
                    logical: 0,
751
                },
752
                message,
753
                tree_ops: built.tree_ops,
754
                edits: built.edits,
755
                kind: if built.resolves {
756
                    ChangeKind::Resolution
757
                } else {
758
                    ChangeKind::Normal
759
                },
760
            };
761
            deps.extend(referenced_changes(&change, &base));
762
            change.deps = deps.into_iter().collect();
763
            change.normalize();
764
            let hash = self.store.put_change(&change)?;
765
            new_changes.push(change.clone());
766
            base.add_unchecked(change);
767
            known.imported.insert(gc.sha.clone(), hash);
768
            last = Some((gc.sha.clone(), base));
769
            report.commits += 1;
770
        }
771
772
        // The bookmark: heads of the old state plus every new change.
773
        report.heads = old_heads.len();
774
        if last.is_none() {
775
            return Ok(report);
776
        }
777
        let mut all = self.load_state(&old_heads)?;
778
        for c in new_changes {
779
            all.add_unchecked(c);
780
        }
781
        let mut heads = all.heads();
782
        heads.sort();
783
        report.heads = heads.len();
784
        let (op_ref, op) = self.head_op()?;
785
        let mut view = op.view.clone();
786
        let old_state_ref = view.bookmarks.get(&self.config.bookmark).copied();
787
        let state_ref = self.store.put_state(&StateObject::new(heads.clone()))?;
788
        view.bookmarks
789
            .insert(self.config.bookmark.clone(), state_ref);
790
        self.record_op(&[op_ref], command, view)?;
791
        // The new state equals the tip's tree when the old one was itself a
792
        // Git commit's tree (fetched or exported), or there was none.
793
        let old_known = old_heads.is_empty()
794
            || old_state_ref.is_some_and(|s| known.exported.values().any(|e| *e == s));
795
        if let (Some(tip), true) = (commits.last(), old_known) {
796
            self.record_git_state(git_dir, &state_ref, &tip.sha)?;
797
        }
798
        // The working copy follows when it was clean on the old heads.
799
        if old_heads.is_empty() {
800
            self.checkout(&heads, true, &format!("{command} (checkout)"))?;
801
        } else {
802
            let snap = self.snapshot(command)?;
803
            let clean = snap.wc.tree_ops.is_empty() && snap.wc.edits.is_empty();
804
            let mut base = snap.base.maximal(&snap.wc.deps);
805
            base.sort();
806
            let mut sorted_old = old_heads.clone();
807
            sorted_old.sort();
808
            if clean && base == sorted_old {
809
                self.checkout(&heads, false, &format!("{command} (checkout)"))?;
810
            }
811
        }
812
        Ok(report)
813
    }
814
815
    /// Add to `st` the changes of every Git commit `rev-list args` lists:
816
    /// an imported commit's change, or the whole state an exported commit
817
    /// was made from (which may hold changes Git never saw).
818
    fn add_git_ancestors(
819
        &self,
820
        st: &mut State,
821
        git_dir: &Path,
822
        args: &[&str],
823
        known: &GitKnown,
824
    ) -> Result<()> {
825
        let mut full = vec!["rev-list"];
826
        full.extend(args);
827
        let out = git(git_dir, &full)?;
828
        for sha in out.lines() {
829
            if let Some(h) = known.imported.get(sha) {
830
                st.add_unchecked(self.store.get_change(h)?);
831
            } else if let Some(state_ref) = known.exported.get(sha) {
832
                let heads = self.store.get_state(state_ref)?.heads;
833
                for h in self.negotiate(&[], &heads)? {
834
                    st.add_unchecked(self.store.get_change(&h)?);
835
                }
836
            }
837
        }
838
        Ok(())
839
    }
840
841
    /// Commits this repository can relate to Oxygen history: imported
842
    /// ones by change, exported ones (through `git_dir`'s marks) by state.
843
    fn git_known(&self, git_dir: &Path) -> Result<GitKnown> {
844
        let imported = self.imported_commits()?;
845
        let marks_dir = self.git_marks_dir(git_dir)?;
846
        let oxy_marks = Self::read_oxy_marks(&marks_dir.join("oxy-marks"))?;
847
        let mut sha_of_mark: HashMap<u64, String> = HashMap::new();
848
        if let Ok(text) = fs::read_to_string(marks_dir.join("git-marks")) {
849
            for line in text.lines() {
850
                if let Some((m, sha)) = line.split_once(' ') {
851
                    if let Ok(m) = m.trim_start_matches(':').parse::<u64>() {
852
                        sha_of_mark.insert(m, sha.trim().to_string());
853
                    }
854
                }
855
            }
856
        }
857
        let mut exported = HashMap::new();
858
        for (state_ref, m) in oxy_marks {
859
            if let Some(sha) = sha_of_mark.get(&m) {
860
                exported.insert(sha.clone(), state_ref);
861
            }
862
        }
863
        for (state_ref, sha) in Self::read_git_states(&marks_dir.join("git-states"))? {
864
            exported.entry(sha).or_insert(state_ref);
865
        }
866
        Ok(GitKnown { imported, exported })
867
    }
868
869
    fn git_author(&self, c: &Change) -> String {
870
        match self.store.get_identity(&c.author) {
871
            Ok(id) if !id.name.is_empty() => format!(
872
                "{} <{}>",
873
                id.name.replace(['<', '>', '\n'], " "),
874
                id.email.replace(['<', '>', '\n', ' '], "")
875
            ),
876
            _ => "oxy <oxy@localhost>".to_string(),
877
        }
878
    }
879
880
    #[allow(clippy::too_many_arguments)]
881
    fn write_commit(
882
        &self,
883
        out: &mut Vec<u8>,
884
        reference: &str,
885
        mark: u64,
886
        author: &str,
887
        secs: u64,
888
        message: &str,
889
        parents: &[String],
890
        snapshot: &Snapshot,
891
    ) -> Result<()> {
892
        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());
893
        for (i, p) in parents.iter().enumerate() {
894
            out.extend_from_slice(
895
                format!("{} {p}\n", if i == 0 { "from" } else { "merge" }).as_bytes(),
896
            );
897
        }
898
        out.extend_from_slice(b"deleteall\n");
899
        let has_gitmodules = snapshot.files.contains_key(".gitmodules");
900
        for (path, f) in &snapshot.files {
901
            if path == ".oxymodules" {
902
                // Git reads `.gitmodules`; derive it unless the tree has one.
903
                if !has_gitmodules {
904
                    if let FileContent::Text { bytes, .. } = &f.content {
905
                        let gm = crate::subrepo::Modules::parse(&String::from_utf8_lossy(bytes))
906
                            .to_gitmodules();
907
                        if !gm.is_empty() {
908
                            out.extend_from_slice(
909
                                format!("M 100644 inline .gitmodules\ndata {}\n", gm.len())
910
                                    .as_bytes(),
911
                            );
912
                            out.extend_from_slice(gm.as_bytes());
913
                            out.push(b'\n');
914
                        }
915
                    }
916
                }
917
                continue;
918
            }
919
            if f.kind == NodeKind::Subrepo {
920
                if let FileContent::Opaque { blobs } = &f.content {
921
                    if let Some(b) = blobs.first() {
922
                        match crate::subrepo::Pin::decode(&self.read_blob(b)?)? {
923
                            crate::subrepo::Pin::Git { sha, .. } => {
924
                                out.extend_from_slice(format!("M 160000 {sha} {path}\n").as_bytes())
925
                            }
926
                            crate::subrepo::Pin::Oxy { .. } => self
927
                                .git_export_skipped
928
                                .set(self.git_export_skipped.get() + 1),
929
                        }
930
                    }
931
                }
932
                continue;
933
            }
934
            let (mode, bytes): (&str, Vec<u8>) = match (&f.kind, &f.content) {
935
                (NodeKind::Dir, _) => continue,
936
                (NodeKind::Symlink, FileContent::Opaque { blobs }) => (
937
                    "120000",
938
                    blobs
939
                        .first()
940
                        .map(|b| self.read_blob(b))
941
                        .transpose()?
942
                        .unwrap_or_default(),
943
                ),
944
                (_, FileContent::Text { bytes, .. }) => {
945
                    (if f.mode.exec { "100755" } else { "100644" }, bytes.clone())
946
                }
947
                (_, FileContent::Opaque { blobs }) => (
948
                    if f.mode.exec { "100755" } else { "100644" },
949
                    blobs
950
                        .first()
951
                        .map(|b| self.read_blob(b))
952
                        .transpose()?
953
                        .unwrap_or_default(),
954
                ),
955
                (_, FileContent::Dir) => continue,
956
            };
957
            out.extend_from_slice(
958
                format!("M {mode} inline {path}\ndata {}\n", bytes.len()).as_bytes(),
959
            );
960
            out.extend_from_slice(&bytes);
961
            out.push(b'\n');
962
        }
963
        out.push(b'\n');
964
        Ok(())
965
    }
966
}