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