Git bridge, export side: one commit per bookmark state via git fast-import, Change-Id/Oxy-Hash/Oxy-State trailers, incremental marks 79ab8528ff62
- Author
- Andy McCall @andymccall <[email protected]>
- When
- · 2026-09-26 20:31 UTC
- Signature
- ✔ signed by Andy McCall
- Hash
79ab8528ff62404fa16704012742b95c6594c6a4fcf08dfddebae0754ac59d6e- Depends on
- 614541c3cebd 83e0677b07fe 982864068a59 ba160f60219e fce44ae9ad29
- Depended on by
- c397a8c36a9f 6f4226bfb87f 544d0541a7e5 d6e0d5b8b339 d256e826ac5d 8b7986dd3cb0 579afd107b9b f0406c3b40ed ee6aa3e81bc8 ce118287d9da 810691ca6ce6 54e2e6309aa3 9935d490ddfb 33be24095b4b d45dab0484ea 58dfe0e134eb 73d592e763ab c3d2ef6104c2 fd280228bdc2 d2e3c39da1b1 fb5b6181618a 7eac74abc586 40a40bb7b798 e83354cdac22 2306a4d24779 ab4d4e31b38d 57d8dc05c9d4 b493d993a9df a87c7b9d563a 6f8aeb996558 dfb23a3a8cfa 958fb27dda7c 03571027793e 7891e21c762f 62794ecc7f74 75167672ebcd 4dee277c327a b7f102fcbb17 8f10a07a1dbe 30ea9c1e70fa 94084087a876 132b884b9a8d 135212de5370 c3069537d7c5 e8b4e156f2fa
- 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
- 8 atoms, 1 tree operation, 5 files
Browse the tree at this change
added crates/oxy-core/src/repo/git.rs +182 −0
| 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 GitExportReport { |
|
| 15 | + | pub exported: usize, |
|
| 16 | + | pub skipped: usize, |
|
| 17 | + | pub branch: String, |
|
| 18 | + | pub heads: usize, |
|
| 19 | + | } |
|
| 20 | + | ||
| 21 | + | fn git(dir: &Path, args: &[&str]) -> Result<String> { |
|
| 22 | + | let out = Command::new("git").arg("-C").arg(dir).args(args).output().map_err(|e| RepoError::Msg(format!("running git: {e}")))?; |
|
| 23 | + | if !out.status.success() { |
|
| 24 | + | return fail(format!("git {} failed: {}", args.join(" "), String::from_utf8_lossy(&out.stderr).trim())); |
|
| 25 | + | } |
|
| 26 | + | Ok(String::from_utf8_lossy(&out.stdout).trim().to_string()) |
|
| 27 | + | } |
|
| 28 | + | ||
| 29 | + | impl Repo { |
|
| 30 | + | fn git_marks_dir(&self, git_dir: &Path) -> Result<PathBuf> { |
|
| 31 | + | let canonical = git_dir.canonicalize().unwrap_or_else(|_| git_dir.to_path_buf()); |
|
| 32 | + | let key = hash_object(ObjectType::Chunk, canonical.to_string_lossy().as_bytes()).short(16); |
|
| 33 | + | let dir = self.oxy.join("git").join(key); |
|
| 34 | + | fs::create_dir_all(&dir)?; |
|
| 35 | + | Ok(dir) |
|
| 36 | + | } |
|
| 37 | + | ||
| 38 | + | fn read_oxy_marks(path: &Path) -> Result<BTreeMap<ObjectRef, u64>> { |
|
| 39 | + | let mut out = BTreeMap::new(); |
|
| 40 | + | if let Ok(text) = fs::read_to_string(path) { |
|
| 41 | + | for line in text.lines() { |
|
| 42 | + | if let Some((h, m)) = line.split_once(' ') { |
|
| 43 | + | if let (Some(h), Ok(m)) = (ObjectRef::from_hex(h), m.parse()) { |
|
| 44 | + | out.insert(h, m); |
|
| 45 | + | } |
|
| 46 | + | } |
|
| 47 | + | } |
|
| 48 | + | } |
|
| 49 | + | Ok(out) |
|
| 50 | + | } |
|
| 51 | + | ||
| 52 | + | /// Export the current bookmark to the Git repository at `git_dir`, |
|
| 53 | + | /// creating a bare one if nothing is there. Each state the bookmark |
|
| 54 | + | /// passed through (per the operation log) becomes one commit whose tree |
|
| 55 | + | /// is that state, so the Git branch is a linear history of full trees. |
|
| 56 | + | pub fn git_export(&self, git_dir: &Path) -> Result<GitExportReport> { |
|
| 57 | + | if !git_dir.join("HEAD").exists() && !git_dir.join(".git").exists() { |
|
| 58 | + | fs::create_dir_all(git_dir)?; |
|
| 59 | + | git(git_dir, &["init", "--bare", "-q"])?; |
|
| 60 | + | } |
|
| 61 | + | let marks_dir = self.git_marks_dir(git_dir)?; |
|
| 62 | + | let git_marks = marks_dir.join("git-marks"); |
|
| 63 | + | let oxy_marks_path = marks_dir.join("oxy-marks"); |
|
| 64 | + | let mut marks = Self::read_oxy_marks(&oxy_marks_path)?; |
|
| 65 | + | let mut next_mark = marks.values().copied().max().unwrap_or(0) + 1; |
|
| 66 | + | ||
| 67 | + | let bookmark = self.config.bookmark.clone(); |
|
| 68 | + | let mut stream: Vec<u8> = Vec::new(); |
|
| 69 | + | let mut report = GitExportReport { branch: bookmark.clone(), heads: self.bookmark_heads()?.len(), ..Default::default() }; |
|
| 70 | + | ||
| 71 | + | // States along the bookmark, oldest first, skipping unchanged and pruned ones. |
|
| 72 | + | let mut ops = self.op_log()?; |
|
| 73 | + | ops.reverse(); |
|
| 74 | + | let mut prev: Option<(ObjectRef, Vec<ChangeHash>)> = None; |
|
| 75 | + | let mut last_mark: Option<u64> = None; |
|
| 76 | + | for (_, op) in &ops { |
|
| 77 | + | let Some(state_ref) = op.view.bookmarks.get(&bookmark).copied() else { continue }; |
|
| 78 | + | if prev.as_ref().is_some_and(|(p, _)| *p == state_ref) { |
|
| 79 | + | continue; |
|
| 80 | + | } |
|
| 81 | + | let Ok(state) = self.store.get_state(&state_ref) else { continue }; |
|
| 82 | + | if state.heads.is_empty() { |
|
| 83 | + | prev = Some((state_ref, vec![])); |
|
| 84 | + | continue; |
|
| 85 | + | } |
|
| 86 | + | if let Some(m) = marks.get(&state_ref) { |
|
| 87 | + | report.skipped += 1; |
|
| 88 | + | last_mark = Some(*m); |
|
| 89 | + | prev = Some((state_ref, state.heads.clone())); |
|
| 90 | + | continue; |
|
| 91 | + | } |
|
| 92 | + | let prev_heads = prev.as_ref().map(|(_, h)| h.clone()).unwrap_or_default(); |
|
| 93 | + | let added = self.negotiate(&prev_heads, &state.heads).unwrap_or_default(); |
|
| 94 | + | let loaded = self.load_state(&state.heads)?; |
|
| 95 | + | let snapshot = loaded.materialize(); |
|
| 96 | + | let (author, secs, mut message) = match added.as_slice() { |
|
| 97 | + | [h] => { |
|
| 98 | + | let c = self.store.get_change(h)?; |
|
| 99 | + | let mut m = c.message.clone(); |
|
| 100 | + | if !m.ends_with('\n') { |
|
| 101 | + | m.push('\n'); |
|
| 102 | + | } |
|
| 103 | + | m.push_str(&format!("\nChange-Id: {}\nOxy-Hash: {}\n", c.change_id.to_hex(), h.to_hex())); |
|
| 104 | + | (self.git_author(&c), c.hlc.wall_ms / 1000, m) |
|
| 105 | + | } |
|
| 106 | + | [] => ("oxy <oxy@localhost>".to_string(), op.hlc.wall_ms / 1000, format!("oxy: {}\n\n", op.command)), |
|
| 107 | + | many => { |
|
| 108 | + | let mut m = format!("oxy: {} ({} changes)\n\n", op.command, many.len()); |
|
| 109 | + | for h in many { |
|
| 110 | + | let c = self.store.get_change(h)?; |
|
| 111 | + | m.push_str(&format!("- {} {}\n", c.change_id.short(8), c.message.lines().next().unwrap_or(""))); |
|
| 112 | + | } |
|
| 113 | + | m.push_str(&format!("\nOxy-Changes: {}\n", many.iter().map(|h| h.to_hex()).collect::<Vec<_>>().join(" "))); |
|
| 114 | + | ("oxy <oxy@localhost>".to_string(), op.hlc.wall_ms / 1000, m) |
|
| 115 | + | } |
|
| 116 | + | }; |
|
| 117 | + | message.push_str(&format!("Oxy-State: {}\n", state_ref.to_hex())); |
|
| 118 | + | if snapshot.is_conflicted() { |
|
| 119 | + | message.push_str("Oxy-Conflicted: true\n"); |
|
| 120 | + | } |
|
| 121 | + | let mark = next_mark; |
|
| 122 | + | next_mark += 1; |
|
| 123 | + | let parents: Vec<u64> = last_mark.into_iter().collect(); |
|
| 124 | + | self.write_commit(&mut stream, "refs/oxy/export", mark, &author, secs, &message, &parents, &snapshot)?; |
|
| 125 | + | marks.insert(state_ref, mark); |
|
| 126 | + | last_mark = Some(mark); |
|
| 127 | + | prev = Some((state_ref, state.heads.clone())); |
|
| 128 | + | report.exported += 1; |
|
| 129 | + | } |
|
| 130 | + | match last_mark { |
|
| 131 | + | Some(m) => stream.extend_from_slice(format!("reset refs/heads/{bookmark}\nfrom :{m}\n\ndone\n").as_bytes()), |
|
| 132 | + | None => stream.extend_from_slice(b"done\n"), |
|
| 133 | + | } |
|
| 134 | + | ||
| 135 | + | let mut cmd = Command::new("git"); |
|
| 136 | + | // `--force`: the scratch ref may jump between unrelated states. |
|
| 137 | + | cmd.arg("-C").arg(git_dir).args(["fast-import", "--quiet", "--done", "--force"]).arg(format!("--export-marks={}", git_marks.display())); |
|
| 138 | + | if git_marks.exists() { |
|
| 139 | + | cmd.arg(format!("--import-marks={}", git_marks.display())); |
|
| 140 | + | } |
|
| 141 | + | let mut child = cmd.stdin(Stdio::piped()).stdout(Stdio::null()).stderr(Stdio::piped()).spawn().map_err(|e| RepoError::Msg(format!("running git fast-import: {e}")))?; |
|
| 142 | + | child.stdin.take().unwrap().write_all(&stream)?; |
|
| 143 | + | let out = child.wait_with_output()?; |
|
| 144 | + | if !out.status.success() { |
|
| 145 | + | return fail(format!("git fast-import failed: {}", String::from_utf8_lossy(&out.stderr).trim())); |
|
| 146 | + | } |
|
| 147 | + | let text: String = marks.iter().map(|(h, m)| format!("{} {m}\n", h.to_hex())).collect(); |
|
| 148 | + | write_atomic(&oxy_marks_path, text.as_bytes())?; |
|
| 149 | + | let _ = git(git_dir, &["update-ref", "-d", "refs/oxy/export"]); |
|
| 150 | + | Ok(report) |
|
| 151 | + | } |
|
| 152 | + | ||
| 153 | + | fn git_author(&self, c: &Change) -> String { |
|
| 154 | + | match self.store.get_identity(&c.author) { |
|
| 155 | + | Ok(id) if !id.name.is_empty() => format!("{} <{}>", id.name.replace(['<', '>', '\n'], " "), id.email.replace(['<', '>', '\n', ' '], "")), |
|
| 156 | + | _ => "oxy <oxy@localhost>".to_string(), |
|
| 157 | + | } |
|
| 158 | + | } |
|
| 159 | + | ||
| 160 | + | #[allow(clippy::too_many_arguments)] |
|
| 161 | + | fn write_commit(&self, out: &mut Vec<u8>, reference: &str, mark: u64, author: &str, secs: u64, message: &str, parents: &[u64], snapshot: &Snapshot) -> Result<()> { |
|
| 162 | + | 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()); |
|
| 163 | + | for (i, p) in parents.iter().enumerate() { |
|
| 164 | + | out.extend_from_slice(format!("{} :{p}\n", if i == 0 { "from" } else { "merge" }).as_bytes()); |
|
| 165 | + | } |
|
| 166 | + | out.extend_from_slice(b"deleteall\n"); |
|
| 167 | + | for (path, f) in &snapshot.files { |
|
| 168 | + | let (mode, bytes): (&str, Vec<u8>) = match (&f.kind, &f.content) { |
|
| 169 | + | (NodeKind::Dir, _) => continue, |
|
| 170 | + | (NodeKind::Symlink, FileContent::Opaque { blobs }) => ("120000", blobs.first().map(|b| self.read_blob(b)).transpose()?.unwrap_or_default()), |
|
| 171 | + | (_, FileContent::Text { bytes, .. }) => (if f.mode.exec { "100755" } else { "100644" }, bytes.clone()), |
|
| 172 | + | (_, FileContent::Opaque { blobs }) => (if f.mode.exec { "100755" } else { "100644" }, blobs.first().map(|b| self.read_blob(b)).transpose()?.unwrap_or_default()), |
|
| 173 | + | (_, FileContent::Dir) => continue, |
|
| 174 | + | }; |
|
| 175 | + | out.extend_from_slice(format!("M {mode} inline {path}\ndata {}\n", bytes.len()).as_bytes()); |
|
| 176 | + | out.extend_from_slice(&bytes); |
|
| 177 | + | out.push(b'\n'); |
|
| 178 | + | } |
|
| 179 | + | out.push(b'\n'); |
|
| 180 | + | Ok(()) |
|
| 181 | + | } |
|
| 182 | + | } |
modified crates/oxy-core/src/repo/mod.rs +2 −0
| … | 53 unchanged lines |
||
| 54 | 54 | mod checkout; |
|
| 55 | 55 | mod config; |
|
| 56 | 56 | mod gc; |
|
| 57 | + | mod git; |
|
| 57 | 58 | mod history; |
|
| 58 | 59 | mod identity; |
|
| 59 | 60 | mod policy; |
|
| … | 5 unchanged lines |
||
| 65 | 66 | pub use checkout::CheckoutReport; |
|
| 66 | 67 | pub use config::{read_config_file, Config}; |
|
| 67 | 68 | pub use gc::GcReport; |
|
| 69 | + | pub use git::GitExportReport; |
|
| 68 | 70 | pub use identity::SignatureStatus; |
|
| 69 | 71 | pub use snapshot::{added_node_paths, has_conflict_markers, is_binary, referenced_changes, render_change, ChangeStatus, PathStatus, SnapshotResult}; |
|
| 70 | 72 | pub use sync::{FetchReport, PullReport}; |
|
| … | 502 unchanged lines |
modified crates/oxy-core/src/repo/tests.rs +64 −0
| … | 870 unchanged lines |
||
| 871 | 871 | repo.undo().unwrap(); |
|
| 872 | 872 | assert_eq!(repo.bookmark_heads().unwrap(), vec![h2d]); |
|
| 873 | 873 | } |
|
| 874 | + | ||
| 875 | + | #[test] |
|
| 876 | + | fn git_export_mirrors_history() { |
|
| 877 | + | hermetic(); |
|
| 878 | + | if std::process::Command::new("git").arg("--version").output().is_err() { |
|
| 879 | + | eprintln!("git not installed; skipping"); |
|
| 880 | + | return; |
|
| 881 | + | } |
|
| 882 | + | let dir = tempfile::tempdir().unwrap(); |
|
| 883 | + | let root = dir.path(); |
|
| 884 | + | let mut repo = Repo::init(&root.join("r")).unwrap().with_home(root.join("home")); |
|
| 885 | + | repo.create_identity("Ada Lovelace", "[email protected]", IdentityScope::User).unwrap(); |
|
| 886 | + | let r = root.join("r"); |
|
| 887 | + | write(&r, "README.md", b"hello\n"); |
|
| 888 | + | write(&r, "src/main.rs", b"fn main() {}\n"); |
|
| 889 | + | let h1 = repo.commit("first", &[]).unwrap(); |
|
| 890 | + | write(&r, "src/main.rs", b"fn main() { run(); }\n"); |
|
| 891 | + | write(&r, "logo.png", b"\x89PNG\0\0"); |
|
| 892 | + | let h2 = repo.commit("second", &[]).unwrap(); |
|
| 893 | + | ||
| 894 | + | let git_dir = root.join("mirror.git"); |
|
| 895 | + | let rep = repo.git_export(&git_dir).unwrap(); |
|
| 896 | + | assert_eq!((rep.exported, rep.skipped, rep.heads), (2, 0, 1)); |
|
| 897 | + | let git = |args: &[&str]| -> String { |
|
| 898 | + | let out = std::process::Command::new("git").arg("-C").arg(&git_dir).args(args).output().unwrap(); |
|
| 899 | + | assert!(out.status.success(), "{}", String::from_utf8_lossy(&out.stderr)); |
|
| 900 | + | String::from_utf8_lossy(&out.stdout).trim().to_string() |
|
| 901 | + | }; |
|
| 902 | + | assert_eq!(git(&["log", "--format=%s", "main"]), "second\nfirst"); |
|
| 903 | + | assert_eq!(git(&["show", "main:src/main.rs"]), "fn main() { run(); }"); |
|
| 904 | + | assert_eq!(git(&["show", "main:README.md"]), "hello"); |
|
| 905 | + | let body = git(&["log", "-1", "--format=%B", "main"]); |
|
| 906 | + | assert!(body.contains(&format!("Oxy-Hash: {}", h2.to_hex())), "{body}"); |
|
| 907 | + | assert!(body.contains("Change-Id: "), "{body}"); |
|
| 908 | + | assert_eq!(git(&["log", "-1", "--format=%an <%ae>", "main"]), "Ada Lovelace <[email protected]>"); |
|
| 909 | + | assert_eq!(git(&["ls-tree", "--name-only", "main"]), "README.md\nlogo.png\nsrc"); |
|
| 910 | + | ||
| 911 | + | // Re-export is incremental. |
|
| 912 | + | let rep = repo.git_export(&git_dir).unwrap(); |
|
| 913 | + | assert_eq!((rep.exported, rep.skipped), (0, 2)); |
|
| 914 | + | ||
| 915 | + | // An independent change makes two heads; the next state is one more |
|
| 916 | + | // commit on the linear branch whose tree is the union. |
|
| 917 | + | repo.checkout(&[h1], false, "checkout").unwrap(); |
|
| 918 | + | write(&r, "other.txt", b"o\n"); |
|
| 919 | + | let h3 = repo.commit("third", &[]).unwrap(); |
|
| 920 | + | let rep = repo.git_export(&git_dir).unwrap(); |
|
| 921 | + | assert_eq!((rep.exported, rep.heads), (1, 2)); |
|
| 922 | + | assert_eq!(git(&["log", "--format=%s", "main"]), "third\nsecond\nfirst"); |
|
| 923 | + | let parents = git(&["rev-list", "--parents", "-1", "main"]); |
|
| 924 | + | assert_eq!(parents.split(' ').count(), 2, "one parent: {parents}"); |
|
| 925 | + | assert_eq!(git(&["show", "main:other.txt"]), "o"); |
|
| 926 | + | assert_eq!(git(&["show", "main:src/main.rs"]), "fn main() { run(); }"); |
|
| 927 | + | assert_eq!(git(&["ls-tree", "--name-only", "main"]), "README.md\nlogo.png\nother.txt\nsrc"); |
|
| 928 | + | let body = git(&["log", "-1", "--format=%B", "main"]); |
|
| 929 | + | assert!(body.contains(&format!("Oxy-Hash: {}", h3.to_hex())) && body.contains("Oxy-State: "), "{body}"); |
|
| 930 | + | // A rewrite is a new state whose one new change is the rewritten one. |
|
| 931 | + | repo.describe(&h3, "third, described").unwrap(); |
|
| 932 | + | let rep = repo.git_export(&git_dir).unwrap(); |
|
| 933 | + | assert_eq!(rep.exported, 1); |
|
| 934 | + | assert_eq!(git(&["log", "-1", "--format=%s", "main"]), "third, described"); |
|
| 935 | + | assert_eq!(git(&["log", "--format=%s", "main"]).lines().count(), 4); |
|
| 936 | + | let _ = h2; |
|
| 937 | + | } |
modified src/cli/mod.rs +12 −0
| … | 114 unchanged lines |
||
| 115 | 115 | }, |
|
| 116 | 116 | /// Verify the signatures on a change (default: every change in the log) |
|
| 117 | 117 | Verify { change: Option<String> }, |
|
| 118 | + | /// Git interoperability |
|
| 119 | + | Git { |
|
| 120 | + | #[command(subcommand)] |
|
| 121 | + | cmd: GitCmd, |
|
| 122 | + | }, |
|
| 118 | 123 | /// Manage the trust policy (who may update which bookmarks) |
|
| 119 | 124 | Policy { |
|
| 120 | 125 | #[command(subcommand)] |
|
| … | 40 unchanged lines |
||
| 161 | 166 | } |
|
| 162 | 167 | ||
| 163 | 168 | #[derive(Subcommand)] |
|
| 169 | + | enum GitCmd { |
|
| 170 | + | /// Export the current bookmark to a Git repository (created bare if absent) |
|
| 171 | + | Export { git_dir: PathBuf }, |
|
| 172 | + | } |
|
| 173 | + | ||
| 174 | + | #[derive(Subcommand)] |
|
| 164 | 175 | enum PolicyCmd { |
|
| 165 | 176 | /// Create the initial policy with your identity as the only root |
|
| 166 | 177 | Init, |
|
| … | 48 unchanged lines |
||
| 215 | 226 | IdentityCmd::Show => identity::show(), |
|
| 216 | 227 | IdentityCmd::Export { path } => identity::export(path), |
|
| 217 | 228 | }, |
|
| 229 | + | Cmd::Git { cmd: GitCmd::Export { git_dir } } => sync::git_export(&git_dir), |
|
| 218 | 230 | Cmd::Policy { cmd } => match cmd { |
|
| 219 | 231 | PolicyCmd::Init => identity::policy_init(), |
|
| 220 | 232 | PolicyCmd::Show => identity::policy_show(), |
|
| … | 7 unchanged lines |
modified src/cli/sync.rs +15 −0
| … | 116 unchanged lines |
||
| 117 | 117 | oxy_server::run_blocking(listener, oxy_server::ServerConfig { root, tokens, tls })?; |
|
| 118 | 118 | Ok(0) |
|
| 119 | 119 | } |
|
| 120 | + | ||
| 121 | + | pub fn git_export(git_dir: &std::path::Path) -> Result<i32> { |
|
| 122 | + | let repo = open()?; |
|
| 123 | + | let r = repo.git_export(git_dir)?; |
|
| 124 | + | println!( |
|
| 125 | + | "Exported {} to {} (branch {}: {}{}); {} already exported.", |
|
| 126 | + | plural(r.exported, "change"), |
|
| 127 | + | git_dir.display(), |
|
| 128 | + | r.branch, |
|
| 129 | + | plural(r.heads, "head"), |
|
| 130 | + | if r.heads > 1 { ", joined by a synthetic merge commit" } else { "" }, |
|
| 131 | + | r.skipped |
|
| 132 | + | ); |
|
| 133 | + | Ok(0) |
|
| 134 | + | } |