Git bridge: oxy git fetch and oxy git push against a Git remote through a bare mirror, incremental import seeded by imported and exported commits, export chained onto fetched tips (spec 0.6p) 30ea9c1e70fa
- Author
- Andy McCall @andymccall <[email protected]>
- When
- · 2026-09-26 23:47 UTC
- Signature
- ✔ signed by Andy McCall
- Hash
30ea9c1e70faf8a3bcdcd8b919147347185a389e36ce266ed07e53a984f25185- Depends on
- 132b884b9a8d 135212de5370 28fc23d7706a 648724d8e0de 6e723b81f5b9 79ab8528ff62 83e0677b07fe 96f7610fa666 982864068a59 c3069537d7c5 d02e545dc265 fce44ae9ad29 ff0af1ee299d
- Depended on by
- c397a8c36a9f d6e0d5b8b339 810691ca6ce6 54e2e6309aa3 9935d490ddfb 58dfe0e134eb fd280228bdc2 fb5b6181618a 2306a4d24779 b493d993a9df dfb23a3a8cfa 958fb27dda7c 03571027793e 7891e21c762f 62794ecc7f74 d42369abf180 05e71ed6cdf1 75167672ebcd 728da0f92161 4dee277c327a 86fe29fd62ea 50a046e439e9 8f10a07a1dbe 2b872d074ddb
- 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
- 47 atoms, 0 tree operations, 7 files
Browse the tree at this change
modified README.md +4 −0
| … | 60 unchanged lines |
||
| 61 | 61 | oxy push # publishes the policy |
|
| 62 | 62 | ``` |
|
| 63 | 63 | ||
| 64 | + | Working alongside Git: `oxy git import <repo> <dest>` converts a Git history, `oxy git fetch <url>` |
|
| 65 | + | brings new commits of a Git remote into the bookmark, `oxy git push <url>` publishes the bookmark as |
|
| 66 | + | commits on top of them, and `oxy git export <dir>` writes a plain Git mirror. |
|
| 67 | + | ||
| 64 | 68 | Keys have a lifecycle: `oxy identity rotate` moves to a fresh key pair (the policy follows), |
|
| 65 | 69 | `oxy identity renew --expires 365` sets an expiry, `oxy identity delegate add bot.identity` |
|
| 66 | 70 | lets another key sign for you, and `oxy identity revoke` retires a key. With several root |
|
| … | 15 unchanged lines |
modified crates/oxy-core/src/repo/git.rs +230 −37
| … | 138 unchanged lines |
||
| 139 | 139 | ChangeId(h.as_bytes()[..16].try_into().unwrap()) |
|
| 140 | 140 | } |
|
| 141 | 141 | ||
| 142 | + | /// Git commits with a known Oxygen counterpart. |
|
| 143 | + | struct GitKnown { |
|
| 144 | + | /// Imported commit → its change. |
|
| 145 | + | imported: HashMap<String, ChangeHash>, |
|
| 146 | + | /// Exported commit (or a fetched tip) → the bookmark state it equals. |
|
| 147 | + | exported: HashMap<String, ObjectRef>, |
|
| 148 | + | } |
|
| 149 | + | ||
| 142 | 150 | #[derive(Debug, Clone, Default, PartialEq, Eq)] |
|
| 143 | 151 | pub struct GitExportReport { |
|
| 144 | 152 | pub exported: usize, |
|
| … | 19 unchanged lines |
||
| 164 | 172 | Ok(dir) |
|
| 165 | 173 | } |
|
| 166 | 174 | ||
| 175 | + | /// `state ref → Git commit sha` for bookmark states that equal a Git |
|
| 176 | + | /// commit's tree (recorded by import and fetch). |
|
| 177 | + | fn read_git_states(path: &Path) -> Result<BTreeMap<ObjectRef, String>> { |
|
| 178 | + | let mut out = BTreeMap::new(); |
|
| 179 | + | if let Ok(text) = fs::read_to_string(path) { |
|
| 180 | + | for line in text.lines() { |
|
| 181 | + | if let Some((h, sha)) = line.split_once(' ') { |
|
| 182 | + | if let Some(h) = ObjectRef::from_hex(h) { |
|
| 183 | + | out.insert(h, sha.trim().to_string()); |
|
| 184 | + | } |
|
| 185 | + | } |
|
| 186 | + | } |
|
| 187 | + | } |
|
| 188 | + | Ok(out) |
|
| 189 | + | } |
|
| 190 | + | ||
| 191 | + | fn record_git_state(&self, git_dir: &Path, state: &ObjectRef, sha: &str) -> Result<()> { |
|
| 192 | + | let path = self.git_marks_dir(git_dir)?.join("git-states"); |
|
| 193 | + | let mut states = Self::read_git_states(&path)?; |
|
| 194 | + | states.insert(*state, sha.to_string()); |
|
| 195 | + | let text: String = states.iter().map(|(h, s)| format!("{} {s}\n", h.to_hex())).collect(); |
|
| 196 | + | write_atomic(&path, text.as_bytes()) |
|
| 197 | + | } |
|
| 198 | + | ||
| 199 | + | /// The bare mirror this repository keeps for the Git remote `url`. |
|
| 200 | + | fn git_mirror(&self, url: &str) -> Result<PathBuf> { |
|
| 201 | + | let key = hash_object(ObjectType::Chunk, url.as_bytes()).short(16); |
|
| 202 | + | let dir = self.oxy.join("git").join(key).join("mirror.git"); |
|
| 203 | + | if !dir.join("HEAD").exists() { |
|
| 204 | + | fs::create_dir_all(&dir)?; |
|
| 205 | + | git(&dir, &["init", "--bare", "-q"])?; |
|
| 206 | + | } |
|
| 207 | + | Ok(dir) |
|
| 208 | + | } |
|
| 209 | + | ||
| 210 | + | /// Git commits already imported into the bookmark, by sha. |
|
| 211 | + | fn imported_commits(&self) -> Result<HashMap<String, ChangeHash>> { |
|
| 212 | + | let mut map = HashMap::new(); |
|
| 213 | + | for e in self.log()? { |
|
| 214 | + | if e.is_working_copy { |
|
| 215 | + | continue; |
|
| 216 | + | } |
|
| 217 | + | if let Some(sha) = e.change.message.lines().rev().find_map(|l| l.strip_prefix("Git-Commit: ")) { |
|
| 218 | + | map.insert(sha.trim().to_string(), e.hash); |
|
| 219 | + | } |
|
| 220 | + | } |
|
| 221 | + | Ok(map) |
|
| 222 | + | } |
|
| 223 | + | ||
| 224 | + | /// Bring the mirror's `branch` up to date with the Git remote `url`. |
|
| 225 | + | /// A remote without that branch yet leaves the mirror as it is. |
|
| 226 | + | fn git_mirror_fetch(&self, mirror: &Path, url: &str, branch: &str) -> Result<()> { |
|
| 227 | + | let spec = format!("+refs/heads/{branch}:refs/heads/{branch}"); |
|
| 228 | + | match git(mirror, &["fetch", "-q", url, &spec]) { |
|
| 229 | + | Ok(_) => Ok(()), |
|
| 230 | + | Err(e) => { |
|
| 231 | + | let ls = git(mirror, &["ls-remote", "--heads", url, branch]).unwrap_or_default(); |
|
| 232 | + | if ls.trim().is_empty() { Ok(()) } else { Err(e) } |
|
| 233 | + | } |
|
| 234 | + | } |
|
| 235 | + | } |
|
| 236 | + | ||
| 237 | + | /// Fetch `branch` of the Git repository at `url` (a path or a Git URL) |
|
| 238 | + | /// through a mirror and import the commits not yet in the bookmark |
|
| 239 | + | /// (spec §23). Works on a repository that already has history: the |
|
| 240 | + | /// new changes join the bookmark like a pull. |
|
| 241 | + | pub fn git_fetch(&self, url: &str, branch: &str) -> Result<GitImportReport> { |
|
| 242 | + | let mirror = self.git_mirror(url)?; |
|
| 243 | + | self.git_mirror_fetch(&mirror, url, branch)?; |
|
| 244 | + | if git(&mirror, &["rev-parse", "--verify", "-q", &format!("refs/heads/{branch}")]).is_err() { |
|
| 245 | + | return fail(format!("{url} has no branch {branch:?}")); |
|
| 246 | + | } |
|
| 247 | + | self.import_git_commits(&mirror, branch, &format!("oxy git fetch {url} {branch}")) |
|
| 248 | + | } |
|
| 249 | + | ||
| 250 | + | /// Export the bookmark to `branch` of the Git repository at `url` |
|
| 251 | + | /// through the mirror and push it. The remote's tip must already have |
|
| 252 | + | /// been fetched (`git_fetch`), so the push fast-forwards. |
|
| 253 | + | pub fn git_push(&self, url: &str, branch: &str) -> Result<GitExportReport> { |
|
| 254 | + | let mirror = self.git_mirror(url)?; |
|
| 255 | + | self.git_mirror_fetch(&mirror, url, branch)?; |
|
| 256 | + | if let Ok(tip) = git(&mirror, &["rev-parse", "--verify", "-q", &format!("refs/heads/{branch}")]) { |
|
| 257 | + | let known = self.git_known(&mirror)?; |
|
| 258 | + | if !known.imported.contains_key(&tip) && !known.exported.contains_key(&tip) { |
|
| 259 | + | return fail(format!("{url} {branch} has commits not yet fetched; run `oxy git fetch {url}` first")); |
|
| 260 | + | } |
|
| 261 | + | } |
|
| 262 | + | let report = self.git_export_as(&mirror, branch)?; |
|
| 263 | + | let spec = format!("refs/heads/{branch}:refs/heads/{branch}"); |
|
| 264 | + | git(&mirror, &["push", "-q", url, &spec])?; |
|
| 265 | + | Ok(report) |
|
| 266 | + | } |
|
| 267 | + | ||
| 167 | 268 | fn read_oxy_marks(path: &Path) -> Result<BTreeMap<ObjectRef, u64>> { |
|
| 168 | 269 | let mut out = BTreeMap::new(); |
|
| 169 | 270 | if let Ok(text) = fs::read_to_string(path) { |
|
| … | 13 unchanged lines |
||
| 183 | 284 | /// passed through (per the operation log) becomes one commit whose tree |
|
| 184 | 285 | /// is that state, so the Git branch is a linear history of full trees. |
|
| 185 | 286 | pub fn git_export(&self, git_dir: &Path) -> Result<GitExportReport> { |
|
| 287 | + | let bookmark = self.config.bookmark.clone(); |
|
| 288 | + | self.git_export_as(git_dir, &bookmark) |
|
| 289 | + | } |
|
| 290 | + | ||
| 291 | + | /// `git_export` to the Git branch `branch`. A state that came from a |
|
| 292 | + | /// Git commit (`oxy git fetch`) is not re-exported: later states chain |
|
| 293 | + | /// onto that commit, so a push back to the Git remote fast-forwards. |
|
| 294 | + | pub fn git_export_as(&self, git_dir: &Path, branch: &str) -> Result<GitExportReport> { |
|
| 186 | 295 | if !git_dir.join("HEAD").exists() && !git_dir.join(".git").exists() { |
|
| 187 | 296 | fs::create_dir_all(git_dir)?; |
|
| 188 | 297 | git(git_dir, &["init", "--bare", "-q"])?; |
|
| … | 2 unchanged lines |
||
| 191 | 300 | let git_marks = marks_dir.join("git-marks"); |
|
| 192 | 301 | let oxy_marks_path = marks_dir.join("oxy-marks"); |
|
| 193 | 302 | let mut marks = Self::read_oxy_marks(&oxy_marks_path)?; |
|
| 303 | + | let git_states = Self::read_git_states(&marks_dir.join("git-states"))?; |
|
| 194 | 304 | let mut next_mark = marks.values().copied().max().unwrap_or(0) + 1; |
|
| 195 | 305 | ||
| 196 | 306 | let bookmark = self.config.bookmark.clone(); |
|
| 197 | 307 | let mut stream: Vec<u8> = Vec::new(); |
|
| 198 | - | let mut report = GitExportReport { branch: bookmark.clone(), heads: self.bookmark_heads()?.len(), ..Default::default() }; |
|
| 308 | + | let mut report = GitExportReport { branch: branch.to_string(), heads: self.bookmark_heads()?.len(), ..Default::default() }; |
|
| 199 | 309 | ||
| 200 | 310 | // States along the bookmark, oldest first, skipping unchanged and pruned ones. |
|
| 201 | 311 | let mut ops = self.op_log()?; |
|
| 202 | 312 | ops.reverse(); |
|
| 203 | 313 | let mut prev: Option<(ObjectRef, Vec<ChangeHash>)> = None; |
|
| 204 | - | let mut last_mark: Option<u64> = None; |
|
| 314 | + | // The fast-import parent of the next commit: `:mark` or a commit sha. |
|
| 315 | + | let mut last_parent: Option<String> = None; |
|
| 205 | 316 | for (_, op) in &ops { |
|
| 206 | 317 | let Some(state_ref) = op.view.bookmarks.get(&bookmark).copied() else { continue }; |
|
| 207 | 318 | if prev.as_ref().is_some_and(|(p, _)| *p == state_ref) { |
|
| … | 4 unchanged lines |
||
| 212 | 323 | prev = Some((state_ref, vec![])); |
|
| 213 | 324 | continue; |
|
| 214 | 325 | } |
|
| 326 | + | if let Some(sha) = git_states.get(&state_ref) { |
|
| 327 | + | report.skipped += 1; |
|
| 328 | + | last_parent = Some(sha.clone()); |
|
| 329 | + | prev = Some((state_ref, state.heads.clone())); |
|
| 330 | + | continue; |
|
| 331 | + | } |
|
| 215 | 332 | if let Some(m) = marks.get(&state_ref) { |
|
| 216 | 333 | report.skipped += 1; |
|
| 217 | - | last_mark = Some(*m); |
|
| 334 | + | last_parent = Some(format!(":{m}")); |
|
| 218 | 335 | prev = Some((state_ref, state.heads.clone())); |
|
| 219 | 336 | continue; |
|
| 220 | 337 | } |
|
| … | 28 unchanged lines |
||
| 249 | 366 | } |
|
| 250 | 367 | let mark = next_mark; |
|
| 251 | 368 | next_mark += 1; |
|
| 252 | - | let parents: Vec<u64> = last_mark.into_iter().collect(); |
|
| 369 | + | let parents: Vec<String> = last_parent.clone().into_iter().collect(); |
|
| 253 | 370 | self.write_commit(&mut stream, "refs/oxy/export", mark, &author, secs, &message, &parents, &snapshot)?; |
|
| 254 | 371 | marks.insert(state_ref, mark); |
|
| 255 | - | last_mark = Some(mark); |
|
| 372 | + | last_parent = Some(format!(":{mark}")); |
|
| 256 | 373 | prev = Some((state_ref, state.heads.clone())); |
|
| 257 | 374 | report.exported += 1; |
|
| 258 | 375 | } |
|
| 259 | - | if let Some(m) = last_mark { |
|
| 260 | - | stream.extend_from_slice(format!("reset refs/heads/{bookmark}\nfrom :{m}\n\n").as_bytes()); |
|
| 376 | + | if let Some(p) = &last_parent { |
|
| 377 | + | stream.extend_from_slice(format!("reset refs/heads/{branch}\nfrom {p}\n\n").as_bytes()); |
|
| 261 | 378 | } |
|
| 262 | 379 | // Tags: a tag whose state was exported as a bookmark state points at |
|
| 263 | 380 | // that commit; otherwise its state gets a commit of its own. |
|
| 264 | 381 | for t in self.tags()? { |
|
| 265 | - | let mark = match marks.get(&t.state) { |
|
| 266 | - | Some(m) => *m, |
|
| 267 | - | None => { |
|
| 382 | + | let target = match (git_states.get(&t.state), marks.get(&t.state)) { |
|
| 383 | + | (Some(sha), _) => sha.clone(), |
|
| 384 | + | (None, Some(m)) => format!(":{m}"), |
|
| 385 | + | (None, None) => { |
|
| 268 | 386 | let loaded = self.load_state(&t.heads)?; |
|
| 269 | 387 | let snapshot = loaded.materialize(); |
|
| 270 | 388 | let mark = next_mark; |
|
| … | 2 unchanged lines |
||
| 273 | 391 | self.write_commit(&mut stream, "refs/oxy/export", mark, "oxy <oxy@localhost>", Hlc::now().wall_ms / 1000, &message, &[], &snapshot)?; |
|
| 274 | 392 | marks.insert(t.state, mark); |
|
| 275 | 393 | report.exported += 1; |
|
| 276 | - | mark |
|
| 394 | + | format!(":{mark}") |
|
| 277 | 395 | } |
|
| 278 | 396 | }; |
|
| 279 | - | stream.extend_from_slice(format!("reset refs/tags/{}\nfrom :{mark}\n\n", t.name).as_bytes()); |
|
| 397 | + | stream.extend_from_slice(format!("reset refs/tags/{}\nfrom {target}\n\n", t.name).as_bytes()); |
|
| 280 | 398 | } |
|
| 281 | 399 | stream.extend_from_slice(b"done\n"); |
|
| 282 | 400 | ||
| … | 23 unchanged lines |
||
| 306 | 424 | if !self.bookmark_heads()?.is_empty() { |
|
| 307 | 425 | return fail("git import needs an empty bookmark; import into a fresh repository"); |
|
| 308 | 426 | } |
|
| 427 | + | self.import_git_commits(git_dir, branch, &format!("oxy git import {} {branch}", git_dir.display())) |
|
| 428 | + | } |
|
| 429 | + | ||
| 430 | + | /// Import the commits of `branch` that the bookmark does not have yet, |
|
| 431 | + | /// then move the bookmark to the union of its heads and theirs. |
|
| 432 | + | fn import_git_commits(&self, git_dir: &Path, branch: &str, command: &str) -> Result<GitImportReport> { |
|
| 309 | 433 | let commits = list_commits(git_dir, branch)?; |
|
| 310 | 434 | let mut cat = CatFile::start(git_dir)?; |
|
| 311 | 435 | let mut report = GitImportReport::default(); |
|
| 312 | - | let mut change_of: HashMap<String, ChangeHash> = HashMap::new(); |
|
| 436 | + | let mut known = self.git_known(git_dir)?; |
|
| 437 | + | let old_heads = self.bookmark_heads()?; |
|
| 438 | + | let mut new_changes: Vec<Change> = Vec::new(); |
|
| 313 | 439 | // The state after the most recently imported commit, reused when the |
|
| 314 | 440 | // next commit's first parent is that commit. |
|
| 315 | 441 | let mut last: Option<(String, State)> = None; |
|
| … | 1 unchanged lines |
||
| 317 | 443 | let mut cache_bytes: usize = 0; |
|
| 318 | 444 | ||
| 319 | 445 | for gc in &commits { |
|
| 446 | + | if known.imported.contains_key(&gc.sha) || known.exported.contains_key(&gc.sha) { |
|
| 447 | + | continue; |
|
| 448 | + | } |
|
| 320 | 449 | // Base: union of every parent's state. |
|
| 321 | 450 | let mut base = match (&gc.parents.first(), &last) { |
|
| 322 | 451 | (Some(p1), Some((sha, st))) if *p1 == sha => st.clone(), |
|
| 323 | - | (Some(p1), _) => self.git_ancestor_state(git_dir, &[p1.as_str()], &change_of)?, |
|
| 452 | + | (Some(p1), _) => { |
|
| 453 | + | let mut st = State::new(); |
|
| 454 | + | self.add_git_ancestors(&mut st, git_dir, &[p1.as_str()], &known)?; |
|
| 455 | + | st |
|
| 456 | + | } |
|
| 324 | 457 | (None, _) => State::new(), |
|
| 325 | 458 | }; |
|
| 326 | 459 | if gc.parents.len() > 1 { |
|
| 327 | 460 | report.merges += 1; |
|
| 328 | 461 | let mut args: Vec<String> = gc.parents[1..].to_vec(); |
|
| 329 | 462 | args.push(format!("^{}", gc.parents[0])); |
|
| 330 | - | let extra = git(git_dir, &["rev-list".to_string()].iter().map(String::as_str).chain(args.iter().map(String::as_str)).collect::<Vec<_>>())?; |
|
| 331 | - | for sha in extra.lines() { |
|
| 332 | - | if let Some(h) = change_of.get(sha) { |
|
| 333 | - | base.add_unchecked(self.store.get_change(h)?); |
|
| 334 | - | } |
|
| 335 | - | } |
|
| 463 | + | let refs: Vec<&str> = args.iter().map(String::as_str).collect(); |
|
| 464 | + | self.add_git_ancestors(&mut base, git_dir, &refs, &known)?; |
|
| 336 | 465 | } |
|
| 337 | 466 | let base_snapshot = base.materialize(); |
|
| 338 | 467 | ||
| … | 47 unchanged lines |
||
| 386 | 515 | change.deps = deps.into_iter().collect(); |
|
| 387 | 516 | change.normalize(); |
|
| 388 | 517 | let hash = self.store.put_change(&change)?; |
|
| 518 | + | new_changes.push(change.clone()); |
|
| 389 | 519 | base.add_unchecked(change); |
|
| 390 | - | change_of.insert(gc.sha.clone(), hash); |
|
| 520 | + | known.imported.insert(gc.sha.clone(), hash); |
|
| 391 | 521 | last = Some((gc.sha.clone(), base)); |
|
| 392 | 522 | report.commits += 1; |
|
| 393 | 523 | } |
|
| 394 | 524 | ||
| 395 | - | // The bookmark: heads of the tip's state; the working copy follows. |
|
| 396 | - | let Some((_, tip_state)) = last else { return Ok(report) }; |
|
| 397 | - | let heads = tip_state.heads(); |
|
| 525 | + | // The bookmark: heads of the old state plus every new change. |
|
| 526 | + | report.heads = old_heads.len(); |
|
| 527 | + | if last.is_none() { |
|
| 528 | + | return Ok(report); |
|
| 529 | + | } |
|
| 530 | + | let mut all = self.load_state(&old_heads)?; |
|
| 531 | + | for c in new_changes { |
|
| 532 | + | all.add_unchecked(c); |
|
| 533 | + | } |
|
| 534 | + | let mut heads = all.heads(); |
|
| 535 | + | heads.sort(); |
|
| 398 | 536 | report.heads = heads.len(); |
|
| 399 | 537 | let (op_ref, op) = self.head_op()?; |
|
| 400 | 538 | let mut view = op.view.clone(); |
|
| 401 | - | view.bookmarks.insert(self.config.bookmark.clone(), self.store.put_state(&StateObject::new(heads.clone()))?); |
|
| 402 | - | self.record_op(&[op_ref], &format!("oxy git import {} {branch}", git_dir.display()), view)?; |
|
| 403 | - | self.checkout(&heads, true, "oxy git import (checkout)")?; |
|
| 539 | + | let old_state_ref = view.bookmarks.get(&self.config.bookmark).copied(); |
|
| 540 | + | let state_ref = self.store.put_state(&StateObject::new(heads.clone()))?; |
|
| 541 | + | view.bookmarks.insert(self.config.bookmark.clone(), state_ref); |
|
| 542 | + | self.record_op(&[op_ref], command, view)?; |
|
| 543 | + | // The new state equals the tip's tree when the old one was itself a |
|
| 544 | + | // Git commit's tree (fetched or exported), or there was none. |
|
| 545 | + | let old_known = old_heads.is_empty() || old_state_ref.is_some_and(|s| known.exported.values().any(|e| *e == s)); |
|
| 546 | + | if let (Some(tip), true) = (commits.last(), old_known) { |
|
| 547 | + | self.record_git_state(git_dir, &state_ref, &tip.sha)?; |
|
| 548 | + | } |
|
| 549 | + | // The working copy follows when it was clean on the old heads. |
|
| 550 | + | if old_heads.is_empty() { |
|
| 551 | + | self.checkout(&heads, true, &format!("{command} (checkout)"))?; |
|
| 552 | + | } else { |
|
| 553 | + | let snap = self.snapshot(command)?; |
|
| 554 | + | let clean = snap.wc.tree_ops.is_empty() && snap.wc.edits.is_empty(); |
|
| 555 | + | let mut base = snap.base.maximal(&snap.wc.deps); |
|
| 556 | + | base.sort(); |
|
| 557 | + | let mut sorted_old = old_heads.clone(); |
|
| 558 | + | sorted_old.sort(); |
|
| 559 | + | if clean && base == sorted_old { |
|
| 560 | + | self.checkout(&heads, false, &format!("{command} (checkout)"))?; |
|
| 561 | + | } |
|
| 562 | + | } |
|
| 404 | 563 | Ok(report) |
|
| 405 | 564 | } |
|
| 406 | 565 | ||
| 407 | - | /// The state holding the changes of every Git ancestor of `shas`. |
|
| 408 | - | fn git_ancestor_state(&self, git_dir: &Path, shas: &[&str], change_of: &HashMap<String, ChangeHash>) -> Result<State> { |
|
| 409 | - | let mut args = vec!["rev-list"]; |
|
| 410 | - | args.extend(shas); |
|
| 411 | - | let out = git(git_dir, &args)?; |
|
| 412 | - | let mut st = State::new(); |
|
| 566 | + | /// Add to `st` the changes of every Git commit `rev-list args` lists: |
|
| 567 | + | /// an imported commit's change, or the whole state an exported commit |
|
| 568 | + | /// was made from (which may hold changes Git never saw). |
|
| 569 | + | fn add_git_ancestors(&self, st: &mut State, git_dir: &Path, args: &[&str], known: &GitKnown) -> Result<()> { |
|
| 570 | + | let mut full = vec!["rev-list"]; |
|
| 571 | + | full.extend(args); |
|
| 572 | + | let out = git(git_dir, &full)?; |
|
| 413 | 573 | for sha in out.lines() { |
|
| 414 | - | if let Some(h) = change_of.get(sha) { |
|
| 574 | + | if let Some(h) = known.imported.get(sha) { |
|
| 415 | 575 | st.add_unchecked(self.store.get_change(h)?); |
|
| 576 | + | } else if let Some(state_ref) = known.exported.get(sha) { |
|
| 577 | + | let heads = self.store.get_state(state_ref)?.heads; |
|
| 578 | + | for h in self.negotiate(&[], &heads)? { |
|
| 579 | + | st.add_unchecked(self.store.get_change(&h)?); |
|
| 580 | + | } |
|
| 581 | + | } |
|
| 582 | + | } |
|
| 583 | + | Ok(()) |
|
| 584 | + | } |
|
| 585 | + | ||
| 586 | + | /// Commits this repository can relate to Oxygen history: imported |
|
| 587 | + | /// ones by change, exported ones (through `git_dir`'s marks) by state. |
|
| 588 | + | fn git_known(&self, git_dir: &Path) -> Result<GitKnown> { |
|
| 589 | + | let imported = self.imported_commits()?; |
|
| 590 | + | let marks_dir = self.git_marks_dir(git_dir)?; |
|
| 591 | + | let oxy_marks = Self::read_oxy_marks(&marks_dir.join("oxy-marks"))?; |
|
| 592 | + | let mut sha_of_mark: HashMap<u64, String> = HashMap::new(); |
|
| 593 | + | if let Ok(text) = fs::read_to_string(marks_dir.join("git-marks")) { |
|
| 594 | + | for line in text.lines() { |
|
| 595 | + | if let Some((m, sha)) = line.split_once(' ') { |
|
| 596 | + | if let Ok(m) = m.trim_start_matches(':').parse::<u64>() { |
|
| 597 | + | sha_of_mark.insert(m, sha.trim().to_string()); |
|
| 598 | + | } |
|
| 599 | + | } |
|
| 600 | + | } |
|
| 601 | + | } |
|
| 602 | + | let mut exported = HashMap::new(); |
|
| 603 | + | for (state_ref, m) in oxy_marks { |
|
| 604 | + | if let Some(sha) = sha_of_mark.get(&m) { |
|
| 605 | + | exported.insert(sha.clone(), state_ref); |
|
| 416 | 606 | } |
|
| 417 | 607 | } |
|
| 418 | - | Ok(st) |
|
| 608 | + | for (state_ref, sha) in Self::read_git_states(&marks_dir.join("git-states"))? { |
|
| 609 | + | exported.entry(sha).or_insert(state_ref); |
|
| 610 | + | } |
|
| 611 | + | Ok(GitKnown { imported, exported }) |
|
| 419 | 612 | } |
|
| 420 | 613 | ||
| 421 | 614 | fn git_author(&self, c: &Change) -> String { |
|
| … | 4 unchanged lines |
||
| 426 | 619 | } |
|
| 427 | 620 | ||
| 428 | 621 | #[allow(clippy::too_many_arguments)] |
|
| 429 | - | fn write_commit(&self, out: &mut Vec<u8>, reference: &str, mark: u64, author: &str, secs: u64, message: &str, parents: &[u64], snapshot: &Snapshot) -> Result<()> { |
|
| 622 | + | fn write_commit(&self, out: &mut Vec<u8>, reference: &str, mark: u64, author: &str, secs: u64, message: &str, parents: &[String], snapshot: &Snapshot) -> Result<()> { |
|
| 430 | 623 | out.extend_from_slice(format!("commit {reference}\nmark :{mark}\nauthor {author} {secs} +0000\ncommitter {author} {secs} +0000\ndata {}\n{message}\n", message.len()).as_bytes()); |
|
| 431 | 624 | for (i, p) in parents.iter().enumerate() { |
|
| 432 | - | out.extend_from_slice(format!("{} :{p}\n", if i == 0 { "from" } else { "merge" }).as_bytes()); |
|
| 625 | + | out.extend_from_slice(format!("{} {p}\n", if i == 0 { "from" } else { "merge" }).as_bytes()); |
|
| 433 | 626 | } |
|
| 434 | 627 | out.extend_from_slice(b"deleteall\n"); |
|
| 435 | 628 | for (path, f) in &snapshot.files { |
|
| … | 15 unchanged lines |
modified crates/oxy-core/src/repo/tests.rs +83 −0
| … | 1783 unchanged lines |
||
| 1784 | 1784 | assert_eq!(similarity(b"", b"x\n"), 0); |
|
| 1785 | 1785 | } |
|
| 1786 | 1786 | ||
| 1787 | + | #[test] |
|
| 1788 | + | fn git_fetch_and_push_round_trip() { |
|
| 1789 | + | hermetic(); |
|
| 1790 | + | if std::process::Command::new("git").arg("--version").output().is_err() { |
|
| 1791 | + | eprintln!("git not installed; skipping"); |
|
| 1792 | + | return; |
|
| 1793 | + | } |
|
| 1794 | + | let dir = tempfile::tempdir().unwrap(); |
|
| 1795 | + | let root = dir.path(); |
|
| 1796 | + | let git_in = |d: &Path, args: &[&str]| -> String { |
|
| 1797 | + | let out = std::process::Command::new("git") |
|
| 1798 | + | .arg("-C") |
|
| 1799 | + | .arg(d) |
|
| 1800 | + | .args(["-c", "user.name=Grace", "-c", "[email protected]", "-c", "commit.gpgsign=false"]) |
|
| 1801 | + | .args(args) |
|
| 1802 | + | .output() |
|
| 1803 | + | .unwrap(); |
|
| 1804 | + | assert!(out.status.success(), "git {args:?}: {}", String::from_utf8_lossy(&out.stderr)); |
|
| 1805 | + | String::from_utf8_lossy(&out.stdout).trim().to_string() |
|
| 1806 | + | }; |
|
| 1807 | + | // A bare "remote" and a work tree that pushes to it. |
|
| 1808 | + | let remote = root.join("remote.git"); |
|
| 1809 | + | fs::create_dir_all(&remote).unwrap(); |
|
| 1810 | + | git_in(&remote, &["init", "-q", "--bare", "-b", "main"]); |
|
| 1811 | + | let work = root.join("work"); |
|
| 1812 | + | git_in(root, &["clone", "-q", remote.to_str().unwrap(), work.to_str().unwrap()]); |
|
| 1813 | + | git_in(&work, &["checkout", "-q", "-b", "main"]); |
|
| 1814 | + | write(&work, "README.md", b"# Project\n"); |
|
| 1815 | + | write(&work, "src/lib.rs", b"pub fn a() {}\n"); |
|
| 1816 | + | git_in(&work, &["add", "."]); |
|
| 1817 | + | git_in(&work, &["commit", "-q", "-m", "initial"]); |
|
| 1818 | + | write(&work, "src/lib.rs", b"pub fn a() {}\npub fn b() {}\n"); |
|
| 1819 | + | git_in(&work, &["commit", "-q", "-am", "second"]); |
|
| 1820 | + | git_in(&work, &["push", "-q", "origin", "main"]); |
|
| 1821 | + | let tip1 = git_in(&work, &["rev-parse", "HEAD"]); |
|
| 1822 | + | ||
| 1823 | + | // Fetch into a fresh oxy repository. |
|
| 1824 | + | let o = root.join("oxy"); |
|
| 1825 | + | let mut repo = Repo::init(&o).unwrap().with_home(root.join("home")); |
|
| 1826 | + | repo.create_identity("Ada", "[email protected]", IdentityScope::User).unwrap(); |
|
| 1827 | + | let url = remote.to_string_lossy().to_string(); |
|
| 1828 | + | let r = repo.git_fetch(&url, "main").unwrap(); |
|
| 1829 | + | assert_eq!((r.commits, r.heads), (2, 1)); |
|
| 1830 | + | assert_eq!(fs::read(o.join("src/lib.rs")).unwrap(), b"pub fn a() {}\npub fn b() {}\n"); |
|
| 1831 | + | assert!(!repo.snapshot("status").unwrap().changed); |
|
| 1832 | + | assert_eq!(repo.git_fetch(&url, "main").unwrap().commits, 0, "nothing new"); |
|
| 1833 | + | ||
| 1834 | + | // An oxy change, pushed back: one commit on top of the Git tip. |
|
| 1835 | + | write(&o, "src/lib.rs", b"pub fn a() {}\npub fn b() {}\npub fn c() {}\n"); |
|
| 1836 | + | write(&o, "NOTES.md", b"from oxy\n"); |
|
| 1837 | + | let h_oxy = repo.commit("add c and notes", &[]).unwrap(); |
|
| 1838 | + | let r = repo.git_push(&url, "main").unwrap(); |
|
| 1839 | + | assert_eq!(r.exported, 1, "{r:?}"); |
|
| 1840 | + | git_in(&work, &["pull", "-q", "--ff-only", "origin", "main"]); |
|
| 1841 | + | assert_eq!(git_in(&work, &["log", "--format=%s", "main"]), "add c and notes\nsecond\ninitial"); |
|
| 1842 | + | assert_eq!(git_in(&work, &["rev-parse", "HEAD~1"]), tip1); |
|
| 1843 | + | assert_eq!(fs::read(work.join("NOTES.md")).unwrap(), b"from oxy\n"); |
|
| 1844 | + | assert_eq!(git_in(&work, &["log", "-1", "--format=%an", "main"]), "Ada"); |
|
| 1845 | + | assert!(git_in(&work, &["log", "-1", "--format=%B", "main"]).contains(&format!("Oxy-Hash: {}", h_oxy.to_hex()))); |
|
| 1846 | + | assert_eq!(repo.git_push(&url, "main").unwrap().exported, 0, "already pushed"); |
|
| 1847 | + | ||
| 1848 | + | // Git moves on; pushing without fetching is refused, fetching brings |
|
| 1849 | + | // the commit in and the working copy follows. |
|
| 1850 | + | write(&work, "README.md", b"# Project\n\nMore.\n"); |
|
| 1851 | + | git_in(&work, &["commit", "-q", "-am", "readme from git"]); |
|
| 1852 | + | git_in(&work, &["push", "-q", "origin", "main"]); |
|
| 1853 | + | let tip2 = git_in(&work, &["rev-parse", "HEAD"]); |
|
| 1854 | + | assert!(repo.git_push(&url, "main").unwrap_err().to_string().contains("fetch")); |
|
| 1855 | + | let r = repo.git_fetch(&url, "main").unwrap(); |
|
| 1856 | + | assert_eq!((r.commits, r.heads), (1, 2), "the Git change and the oxy change commute"); |
|
| 1857 | + | assert_eq!(fs::read(o.join("README.md")).unwrap(), b"# Project\n\nMore.\n"); |
|
| 1858 | + | assert_eq!(fs::read(o.join("NOTES.md")).unwrap(), b"from oxy\n"); |
|
| 1859 | + | assert!(!repo.snapshot("status").unwrap().changed); |
|
| 1860 | + | // Another oxy change pushes on top of Git's newest commit. |
|
| 1861 | + | write(&o, "NOTES.md", b"from oxy\nagain\n"); |
|
| 1862 | + | repo.commit("notes again", &[]).unwrap(); |
|
| 1863 | + | assert_eq!(repo.git_push(&url, "main").unwrap().exported, 1); |
|
| 1864 | + | git_in(&work, &["pull", "-q", "--ff-only", "origin", "main"]); |
|
| 1865 | + | assert_eq!(git_in(&work, &["rev-parse", "HEAD~1"]), tip2); |
|
| 1866 | + | assert_eq!(fs::read(work.join("NOTES.md")).unwrap(), b"from oxy\nagain\n"); |
|
| 1867 | + | assert_eq!(git_in(&work, &["log", "--format=%s", "main"]).lines().count(), 5); |
|
| 1868 | + | } |
|
| 1869 | + |
modified docs/plan.md +2 −1
| … | 28 unchanged lines |
||
| 29 | 29 | - [x] User request (2026-09-27, mid-loop): make `oxy log` and all output look modern like `git log` with lines, spacing, and glyphs when the terminal supports them. (Done: rail-and-bullet log with `-n`/`-v`, relative times, authors, signature marks; status/show/op log/tags/workspaces/remotes/fetch/push/policy/identity restyled; ASCII fallback when piped or with `--plain`/`NO_COLOR`/non-UTF-8 locale.) |
|
| 30 | 30 | - [x] Identity lifecycle: `oxy identity rotate|revoke`, expiry, delegates (`oxy identity delegate add|remove`), multi-signer policy updates. (Done: rotation as supersession with chain-aware policy authorization, renew, delegates, revocation attestations and local markers, expiry checks, root add/remove/threshold, proposal files and `oxy policy sign`; spec 0.6n.) |
|
| 31 | 31 | - [x] Push and serve the chain of policies since the receiver's serial, so several offline rotations or updates publish in one push or fetch. (Done in iteration 12: `.oxy/policies/` index, `refs` history, bundle policies.) |
|
| 32 | - | - [ ] Rename detection on Git import (similarity ≥ 50%); `oxy git fetch`/`push` against a Git remote via a mirror. |
|
| 32 | + | - [x] Rename detection on Git import (similarity ≥ 50%); `oxy git fetch`/`push` against a Git remote via a mirror. (Done: detection lives in the change builder, so working-copy renames are tracked too; fetch/push through `.oxy/git/<key>/mirror.git` with fast-forward chaining; spec 0.6p.) |
|
| 33 | 33 | - [ ] Hardening: property tests for the CBOR decoder and pack reader against random and mutated inputs; a concurrent-push test against the server; a 1 GB single-file benchmark for chunking and blobs. |
|
| 34 | 34 | - [ ] Documentation: `docs/guide.md` (user guide by task) and `docs/protocol.md` (for other implementers, derived from spec §16 and the wire encodings). |
|
| 35 | 35 | - [ ] Spec review pass: resolve or re-word every "MAY ... until" allowance introduced during implementation, and close what can be closed in §27. |
|
| … | 1 unchanged lines |
||
| 37 | 37 | ## Log |
|
| 38 | 38 | ||
| 39 | 39 | - 2026-09-26 23:01 plan created. |
|
| 40 | + | - 2026-09-27 07:20 iteration 13: rename detection (snapshots and Git import) and oxy git fetch/push via a mirror (spec 0.6p). 108 tests. |
|
| 40 | 41 | - 2026-09-27 06:30 iteration 12: identity lifecycle and multi-signer policy updates; policy chains on fetch and push (spec 0.6n). 106 tests. |
|
| 41 | 42 | - 2026-09-27 05:45 iteration 11: modern CLI output across all commands (user request). 105 tests. |
|
| 42 | 43 | - 2026-09-27 05:15 iteration 10: orphaned references reified on rewrite (spec 0.6m); gc fixed to tolerate old views whose working copies were pruned under a zero op-retention during development (this repo's oldest 20 operations). 104 tests. |
|
| … | 9 unchanged lines |
modified docs/spec.md +6 −3
| … | 1 unchanged lines |
||
| 2 | 2 | ||
| 3 | 3 | | | | |
|
| 4 | 4 | |---|---| |
|
| 5 | - | | **Status** | Draft 0.6o | |
|
| 5 | + | | **Status** | Draft 0.6p | |
|
| 6 | 6 | | **Format version** | 1 | |
|
| 7 | 7 | | **Date** | 2026-09-26 | |
|
| 8 | 8 | | **Audience** | Implementers, security reviewers, and contributors | |
|
| … | 772 unchanged lines |
||
| 781 | 781 | ||
| 782 | 782 | - **Import.** Each Git commit becomes one change whose edits are the diff between the union of its parents' states and its tree, so a merge commit becomes a resolution exactly when the parents conflict in the change model, and an ordinary change otherwise. The commit's author and hash are recorded as `Git-Author` and `Git-Commit` trailers; the author key is the zero KeyId, since nobody can sign on the original author's behalf. Submodule entries are skipped. Renames are detected on import exactly as for a snapshot (§7.1, similarity ≥ 50%), then tracked exactly. The Git commit's `Change-Id` trailer, if present and 128 bits, becomes the ChangeId; otherwise one is derived as BLAKE3-128 of the Git commit hash, so re-import is idempotent. Note that a change's own closure is what it depends on, not the Git snapshot: the imported bookmark's state equals the Git tree, but checking out an individual imported change yields only the files it and its dependencies touch. |
|
| 783 | 783 | - **Export.** Each state along a bookmark becomes a Git commit whose tree is the materialized state. The ChangeId is written as a `Change-Id:` trailer and the ChangeHash as `Oxy-Hash:`. Conflicted states are exported with §9.5 markers and an `Oxy-Conflicted: true` trailer. Signatures are not exported; Git commits MAY be additionally signed with the Ed25519 key via SSH signature format. |
|
| 784 | + | - **Fetch and push against a Git remote.** `oxy git fetch URL` keeps a bare mirror under `.oxy/git/` for the remote, fetches the branch into it, and imports the commits the bookmark does not have yet, seeding the import with the commits already known: an imported commit maps to its change, and a commit this repository exported maps to the bookmark state it was made from (so a Git commit on top of exported work is based on everything that work contained, including changes Git never saw). The bookmark moves to the union of its heads and the new changes, like a pull; the working copy follows when it was clean. A fetched state whose previous state was itself a Git tree is remembered as equal to the fetched tip. `oxy git push URL` refuses until the remote's tip is known, exports the bookmark's later states as commits chained onto that tip, and pushes the branch, so the push is always a fast-forward. |
|
| 784 | 785 | - **Round trip.** Import followed by export MUST reproduce every tree hash. Export followed by import MUST reproduce every ChangeId. |
|
| 785 | 786 | ||
| 786 | 787 | ## 24. Command-line interface |
|
| … | 15 unchanged lines |
||
| 802 | 803 | oxy identity create|show|export|rotate|renew|revoke|delegate |
|
| 803 | 804 | oxy policy init|show|grant|revoke|root|sign |
|
| 804 | 805 | oxy remote add|trust|list oxy gc |
|
| 805 | - | oxy git import|export|fetch|push |
|
| 806 | + | oxy git import|export|fetch|push (fetch and push go through a bare mirror under .oxy/git/) |
|
| 806 | 807 | ``` |
|
| 807 | 808 | ||
| 808 | 809 | Change arguments accept a ChangeId prefix, a ChangeHash prefix (with `#`), a bookmark, `@` for the working-copy change, and `@-` for its parent. Ambiguous prefixes are an error. |
|
| … | 37 unchanged lines |
||
| 846 | 847 | ||
| 847 | 848 | --- |
|
| 848 | 849 | ||
| 849 | - | *Changelog.* Draft 0.6o (2026-09-27): rename detection on snapshot and Git import records moves of the existing node (§7.1, §23). |
|
| 850 | + | *Changelog.* Draft 0.6p (2026-09-27): `oxy git fetch` and `oxy git push` against a Git remote through a mirror, with incremental import seeded by imported and exported commits (§23). |
|
| 851 | + | ||
| 852 | + | Draft 0.6o (2026-09-27): rename detection on snapshot and Git import records moves of the existing node (§7.1, §23). |
|
| 850 | 853 | ||
| 851 | 854 | Draft 0.6n (2026-09-27): identity lifecycle (§14.2): every identity edit is a rotation that supersedes rather than revokes, expiry bounds authenticated changes, revocations recorded locally; policy proposals for multi-signer updates, chain-aware authorization, bootstrap rule, and policy history on `refs` and in push bundles (§14.5, §16.3). |
|
| 852 | 855 | ||
| … | 43 unchanged lines |
modified src/cli/mod.rs +21 −2
| … | 282 unchanged lines |
||
| 283 | 283 | #[derive(Subcommand)] |
|
| 284 | 284 | enum GitCmd { |
|
| 285 | 285 | /// Export the current bookmark to a Git repository (created bare if absent) |
|
| 286 | - | Export { git_dir: PathBuf }, |
|
| 286 | + | Export { |
|
| 287 | + | git_dir: PathBuf, |
|
| 288 | + | /// Git branch to write (default: the bookmark name) |
|
| 289 | + | #[arg(long)] |
|
| 290 | + | branch: Option<String>, |
|
| 291 | + | }, |
|
| 292 | + | /// Fetch a branch from a Git remote (path or URL) into the current bookmark |
|
| 293 | + | Fetch { |
|
| 294 | + | url: String, |
|
| 295 | + | #[arg(long, default_value = "main")] |
|
| 296 | + | branch: String, |
|
| 297 | + | }, |
|
| 298 | + | /// Push the current bookmark to a branch of a Git remote (fetch first) |
|
| 299 | + | Push { |
|
| 300 | + | url: String, |
|
| 301 | + | #[arg(long, default_value = "main")] |
|
| 302 | + | branch: String, |
|
| 303 | + | }, |
|
| 287 | 304 | /// Import a Git branch into a new Oxygen repository at DEST |
|
| 288 | 305 | Import { |
|
| 289 | 306 | git_dir: PathBuf, |
|
| … | 125 unchanged lines |
||
| 415 | 432 | DelegateCmd::List => identity::delegate_list(), |
|
| 416 | 433 | }, |
|
| 417 | 434 | }, |
|
| 418 | - | Cmd::Git { cmd: GitCmd::Export { git_dir } } => sync::git_export(&git_dir), |
|
| 435 | + | Cmd::Git { cmd: GitCmd::Export { git_dir, branch } } => sync::git_export(&git_dir, branch.as_deref()), |
|
| 436 | + | Cmd::Git { cmd: GitCmd::Fetch { url, branch } } => sync::git_fetch(&url, &branch), |
|
| 437 | + | Cmd::Git { cmd: GitCmd::Push { url, branch } } => sync::git_push(&url, &branch), |
|
| 419 | 438 | Cmd::Git { cmd: GitCmd::Import { git_dir, dest, branch } } => sync::git_import(&git_dir, &dest, &branch), |
|
| 420 | 439 | Cmd::Policy { cmd } => match cmd { |
|
| 421 | 440 | PolicyCmd::Init => identity::policy_init(), |
|
| … | 14 unchanged lines |
modified src/cli/sync.rs +52 −2
| … | 140 unchanged lines |
||
| 141 | 141 | Ok(0) |
|
| 142 | 142 | } |
|
| 143 | 143 | ||
| 144 | - | pub fn git_export(git_dir: &std::path::Path) -> Result<i32> { |
|
| 144 | + | pub fn git_export(git_dir: &std::path::Path, branch: Option<&str>) -> Result<i32> { |
|
| 145 | 145 | let repo = open()?; |
|
| 146 | 146 | let sp = ui::spinner("Exporting to Git…"); |
|
| 147 | - | let r = repo.git_export(git_dir)?; |
|
| 147 | + | let r = match branch { |
|
| 148 | + | Some(b) => repo.git_export_as(git_dir, b)?, |
|
| 149 | + | None => repo.git_export(git_dir)?, |
|
| 150 | + | }; |
|
| 148 | 151 | sp.clear(); |
|
| 149 | 152 | println!( |
|
| 150 | 153 | "{} {} to {} as branch {} {}", |
|
| … | 6 unchanged lines |
||
| 157 | 160 | Ok(0) |
|
| 158 | 161 | } |
|
| 159 | 162 | ||
| 163 | + | pub fn git_fetch(url: &str, branch: &str) -> Result<i32> { |
|
| 164 | + | let repo = open()?; |
|
| 165 | + | let sp = ui::spinner(&format!("Fetching {url} {branch}…")); |
|
| 166 | + | let r = repo.git_fetch(url, branch)?; |
|
| 167 | + | sp.clear(); |
|
| 168 | + | if r.commits == 0 { |
|
| 169 | + | println!("{} {}", ui::done("Fetched"), ui::dim(format!("nothing new from {url} {branch}"))); |
|
| 170 | + | } else { |
|
| 171 | + | println!( |
|
| 172 | + | "{} {} ({} merges) from {} {}{}{} now has {}", |
|
| 173 | + | ui::done("Fetched"), |
|
| 174 | + | plural(r.commits, "new commit"), |
|
| 175 | + | r.merges, |
|
| 176 | + | ui::path(url), |
|
| 177 | + | ui::bookmark(branch), |
|
| 178 | + | ui::sep(), |
|
| 179 | + | ui::bookmark(&repo.config.bookmark), |
|
| 180 | + | plural(r.heads, "head") |
|
| 181 | + | ); |
|
| 182 | + | if !repo.snapshot("oxy git fetch")?.changed { |
|
| 183 | + | println!(" {} {}", ui::note(), ui::dim("working copy updated")); |
|
| 184 | + | } else { |
|
| 185 | + | println!(" {} {}", ui::note(), ui::dim("run oxy checkout to update the working copy")); |
|
| 186 | + | } |
|
| 187 | + | } |
|
| 188 | + | if r.skipped_submodules > 0 { |
|
| 189 | + | println!(" {} {}", ui::note(), ui::dim(format!("skipped {} submodule entries", r.skipped_submodules))); |
|
| 190 | + | } |
|
| 191 | + | Ok(0) |
|
| 192 | + | } |
|
| 193 | + | ||
| 194 | + | pub fn git_push(url: &str, branch: &str) -> Result<i32> { |
|
| 195 | + | let repo = open()?; |
|
| 196 | + | let sp = ui::spinner(&format!("Pushing to {url} {branch}…")); |
|
| 197 | + | let r = repo.git_push(url, branch)?; |
|
| 198 | + | sp.clear(); |
|
| 199 | + | println!( |
|
| 200 | + | "{} {} to {} {} {}", |
|
| 201 | + | ui::done("Pushed"), |
|
| 202 | + | plural(r.exported, "new commit"), |
|
| 203 | + | ui::path(url), |
|
| 204 | + | ui::bookmark(&r.branch), |
|
| 205 | + | ui::dim(format!("({} already there)", r.skipped)) |
|
| 206 | + | ); |
|
| 207 | + | Ok(0) |
|
| 208 | + | } |
|
| 209 | + | ||
| 160 | 210 | pub fn git_import(git_dir: &std::path::Path, dest: &std::path::Path, branch: &str) -> Result<i32> { |
|
| 161 | 211 | let repo = Repo::init(dest)?; |
|
| 162 | 212 | let started = std::time::Instant::now(); |
|
| … | 30 unchanged lines |