git_export_graph: a deterministic Git export from the change graph, for the hub's Git view (refs andymccall/oxygenhub#355) 6f4226bfb87f
- Author
- Andy McCall @andymccall <[email protected]>
- via
- agent
agent:claude@andymccall <[email protected]> · keyless, under your grant for changes,refs,seal · key 677a83b5342b
- When
- · 2026-11-01 00:30 UTC
- Signature
- ◈ sealed by agent:claude@andymccall
- Hash
6f4226bfb87f003950fd4f05bec407b086935d21b865a9465af7ee958eb246ca- Depends on
- 16aa9e03d85b 2452b4407ab7 62794ecc7f74 75167672ebcd 79ab8528ff62 7eac74abc586 8fd78eb52ad2 91334b7fa574 982864068a59 ff0af1ee299d
- Depended on by
- 2f0b4b0651ea c397a8c36a9f
- In bookmarks
- main
- In tags
- v0.10.0 v0.11.0
- Edits
- 6 atoms, 0 tree operations, 4 files
Browse the tree at this change
modified CHANGELOG.md +1 −0
| … | 1 unchanged lines |
||
| 2 | 2 | ||
| 3 | 3 | ## Unreleased |
|
| 4 | 4 | ||
| 5 | + | - **Added: a Git export from the change graph (oxygenhub#355).** oxy-core's `git_export_graph` writes every bookmark and tag to a bare Git repository so that the same changes give the same commits wherever it runs: each change a commit whose parents are its dependencies, each bookmark a branch (a merge of its heads when it has several), tags as tags, incrementally. It is what OxygenHub serves to `git clone`. (`oxy git export` still follows the checkout's own history.) |
|
| 5 | 6 | - **Added: `oxy mcp`, Oxygen for AI agents (#159).** An MCP server over stdio for a repository: an agent calls typed tools (status, diff, log, show, cat, blame, bookmarks, tags, conflicts, op log, identity, commit, amend, describe, fetch, pull, push, tlog verify) and gets each command's JSON back instead of reading a shell. It signs as the identity its environment configures, so started with an agent home every change is sealed via the agent with nothing to forget per command, and says who in its `initialize` reply. Commits name their paths (or `all: true`); undo and forced pushes are off unless started with `--allow-destructive`. The protocol itself is oxy-core's `mcp` module, for `oh mcp` to reuse. See docs/mcp.md. |
|
| 6 | 7 | - **Icons for HaikuDepot (#157).** `packaging/haikuports/icons/` has oxy's and oh's icons as HVIF (Haiku's vector format) with 16, 32 and 64 px PNGs, written by `make-icons.py`: the Oxygen mark on a pale tile for oxy, on OxygenHub's dark tile for oh, the same family as the VS Code extensions. |
|
| 7 | 8 | ||
| … | 307 unchanged lines |
modified crates/oxy-core/src/repo/git.rs +298 −8
| … | 1715 unchanged lines |
||
| 1716 | 1716 | } |
|
| 1717 | 1717 | ||
| 1718 | 1718 | fn git_author(&self, c: &Change) -> String { |
|
| 1719 | - | match self.store.get_identity(&c.author) { |
|
| 1720 | - | Ok(id) if !id.name.is_empty() => format!( |
|
| 1721 | - | "{} <{}>", |
|
| 1722 | - | id.name.replace(['<', '>', '\n'], " "), |
|
| 1723 | - | id.email.replace(['<', '>', '\n', ' '], "") |
|
| 1724 | - | ), |
|
| 1725 | - | _ => "oxy <oxy@localhost>".to_string(), |
|
| 1726 | - | } |
|
| 1719 | + | self.git_author_key(&c.author) |
|
| 1727 | 1720 | } |
|
| 1728 | 1721 | ||
| 1729 | 1722 | #[allow(clippy::too_many_arguments)] |
|
| … | 379 unchanged lines |
||
| 2109 | 2102 | h.len(), |
|
| 2110 | 2103 | w.len() |
|
| 2111 | 2104 | ) |
|
| 2105 | + | } |
|
| 2106 | + | ||
| 2107 | + | /// What `git_export_graph` wrote. |
|
| 2108 | + | #[derive(Debug, Clone, Default, PartialEq, Eq)] |
|
| 2109 | + | pub struct GitGraphReport { |
|
| 2110 | + | /// Changes written as commits this time (the rest were exported before). |
|
| 2111 | + | pub commits: usize, |
|
| 2112 | + | /// Branches and tags pointed at their commits. |
|
| 2113 | + | pub branches: usize, |
|
| 2114 | + | pub tags: usize, |
|
| 2115 | + | /// Bookmarks or tags whose names Git does not take as refs. |
|
| 2116 | + | pub skipped_refs: Vec<String>, |
|
| 2117 | + | /// Subrepo nodes with Oxygen pins, which Git cannot represent. |
|
| 2118 | + | pub skipped_subrepos: usize, |
|
| 2119 | + | } |
|
| 2120 | + | ||
| 2121 | + | /// A bookmark or tag name Git takes as a ref without escaping. |
|
| 2122 | + | fn git_ref_ok(name: &str) -> bool { |
|
| 2123 | + | !name.is_empty() |
|
| 2124 | + | && !name.starts_with(['-', '/', '.']) |
|
| 2125 | + | && !name.ends_with(['/', '.']) |
|
| 2126 | + | && !name.contains("..") |
|
| 2127 | + | && !name.contains("//") |
|
| 2128 | + | && !name.ends_with(".lock") |
|
| 2129 | + | && name |
|
| 2130 | + | .bytes() |
|
| 2131 | + | .all(|b| b.is_ascii_alphanumeric() || b"-_./+".contains(&b)) |
|
| 2132 | + | } |
|
| 2133 | + | ||
| 2134 | + | impl Repo { |
|
| 2135 | + | /// Export every bookmark and tag to the bare Git repository at |
|
| 2136 | + | /// `git_dir` from the change graph (spec §23, oxygenhub D1), for a hub |
|
| 2137 | + | /// to serve to Git clients. Unlike `git_export`, which follows one |
|
| 2138 | + | /// checkout's operation log, this depends only on the changes, so the |
|
| 2139 | + | /// same changes give the same Git commits wherever it runs: |
|
| 2140 | + | /// |
|
| 2141 | + | /// - each change is a commit whose tree is the state of the change and |
|
| 2142 | + | /// everything it depends on, whose parents are its dependencies' |
|
| 2143 | + | /// commits, with the change's author, time and message and its |
|
| 2144 | + | /// `Change-Id:` and `Oxy-Hash:` trailers (a change that depends on |
|
| 2145 | + | /// nothing is a root, so Git sees Oxygen's dependencies, not the order |
|
| 2146 | + | /// changes were made in); |
|
| 2147 | + | /// - a bookmark with one head is a branch at that head's commit; with |
|
| 2148 | + | /// several, a merge of their commits whose tree is the bookmark's |
|
| 2149 | + | /// state, at the newest head's time; |
|
| 2150 | + | /// - a tag points at its heads' commit, or such a merge. |
|
| 2151 | + | /// |
|
| 2152 | + | /// Commits already exported are remembered (marks), so a later run |
|
| 2153 | + | /// writes only new changes. Branches for bookmarks that are gone are |
|
| 2154 | + | /// deleted. |
|
| 2155 | + | pub fn git_export_graph(&self, git_dir: &Path) -> Result<GitGraphReport> { |
|
| 2156 | + | if !git_dir.join("HEAD").exists() { |
|
| 2157 | + | fs::create_dir_all(git_dir)?; |
|
| 2158 | + | git(git_dir, &["init", "--bare", "-q"])?; |
|
| 2159 | + | } |
|
| 2160 | + | let marks_dir = self.git_marks_dir(git_dir)?; |
|
| 2161 | + | let git_marks = marks_dir.join("git-marks"); |
|
| 2162 | + | let graph_marks = marks_dir.join("graph-marks"); |
|
| 2163 | + | let mut marks = Self::read_oxy_marks(&graph_marks)?; |
|
| 2164 | + | let mut next = marks.values().copied().max().unwrap_or(0) + 1; |
|
| 2165 | + | let mut report = GitGraphReport::default(); |
|
| 2166 | + | let mut stream: Vec<u8> = Vec::new(); |
|
| 2167 | + | ||
| 2168 | + | let mut refs: Vec<(String, Vec<ChangeHash>)> = Vec::new(); |
|
| 2169 | + | for b in self.bookmarks()? { |
|
| 2170 | + | if git_ref_ok(&b.name) { |
|
| 2171 | + | refs.push((format!("refs/heads/{}", b.name), b.heads)); |
|
| 2172 | + | } else { |
|
| 2173 | + | report.skipped_refs.push(b.name); |
|
| 2174 | + | } |
|
| 2175 | + | } |
|
| 2176 | + | let tags = self.tags()?; |
|
| 2177 | + | for t in &tags { |
|
| 2178 | + | if git_ref_ok(&t.name) { |
|
| 2179 | + | refs.push((format!("refs/tags/{}", t.name), t.heads.clone())); |
|
| 2180 | + | } else { |
|
| 2181 | + | report.skipped_refs.push(t.name.clone()); |
|
| 2182 | + | } |
|
| 2183 | + | } |
|
| 2184 | + | ||
| 2185 | + | // Every change the refs reach, dependencies before dependents: a |
|
| 2186 | + | // depth-first walk in hash order, so the order is the same everywhere. |
|
| 2187 | + | let mut order: Vec<ChangeHash> = Vec::new(); |
|
| 2188 | + | let mut seen: HashSet<ChangeHash> = HashSet::new(); |
|
| 2189 | + | let mut metas: HashMap<ChangeHash, crate::state::ChangeMeta> = HashMap::new(); |
|
| 2190 | + | let mut roots: Vec<ChangeHash> = refs.iter().flat_map(|(_, h)| h.iter().copied()).collect(); |
|
| 2191 | + | roots.sort(); |
|
| 2192 | + | roots.dedup(); |
|
| 2193 | + | for root in roots { |
|
| 2194 | + | if !seen.insert(root) { |
|
| 2195 | + | continue; |
|
| 2196 | + | } |
|
| 2197 | + | let mut stack: Vec<(ChangeHash, bool)> = vec![(root, false)]; |
|
| 2198 | + | while let Some((h, done)) = stack.pop() { |
|
| 2199 | + | if done { |
|
| 2200 | + | order.push(h); |
|
| 2201 | + | continue; |
|
| 2202 | + | } |
|
| 2203 | + | stack.push((h, true)); |
|
| 2204 | + | let meta = self.store.get_change_meta(&h)?; |
|
| 2205 | + | let mut deps = meta.deps.clone(); |
|
| 2206 | + | deps.sort(); |
|
| 2207 | + | for d in deps.into_iter().rev() { |
|
| 2208 | + | if seen.insert(d) { |
|
| 2209 | + | stack.push((d, false)); |
|
| 2210 | + | } |
|
| 2211 | + | } |
|
| 2212 | + | metas.insert(h, meta); |
|
| 2213 | + | } |
|
| 2214 | + | } |
|
| 2215 | + | ||
| 2216 | + | for h in &order { |
|
| 2217 | + | if marks.contains_key(h) { |
|
| 2218 | + | continue; |
|
| 2219 | + | } |
|
| 2220 | + | let meta = &metas[h]; |
|
| 2221 | + | let snapshot = self.load_state(&[*h])?.materialize(); |
|
| 2222 | + | let mut message = meta.message.clone(); |
|
| 2223 | + | if !message.ends_with('\n') { |
|
| 2224 | + | message.push('\n'); |
|
| 2225 | + | } |
|
| 2226 | + | message.push_str(&format!( |
|
| 2227 | + | "\nChange-Id: {}\nOxy-Hash: {}\n", |
|
| 2228 | + | meta.change_id.to_hex(), |
|
| 2229 | + | h.to_hex() |
|
| 2230 | + | )); |
|
| 2231 | + | if snapshot.is_conflicted() { |
|
| 2232 | + | message.push_str("Oxy-Conflicted: true\n"); |
|
| 2233 | + | } |
|
| 2234 | + | let mut deps = meta.deps.clone(); |
|
| 2235 | + | deps.sort(); |
|
| 2236 | + | let parents: Vec<String> = deps |
|
| 2237 | + | .iter() |
|
| 2238 | + | .filter_map(|d| marks.get(d)) |
|
| 2239 | + | .map(|m| format!(":{m}")) |
|
| 2240 | + | .collect(); |
|
| 2241 | + | let mark = next; |
|
| 2242 | + | next += 1; |
|
| 2243 | + | if parents.is_empty() { |
|
| 2244 | + | // Without `from`, fast-import would make the scratch ref's |
|
| 2245 | + | // tip its parent; a change that depends on nothing is a root. |
|
| 2246 | + | stream.extend_from_slice(b"reset refs/oxy/export\n\n"); |
|
| 2247 | + | } |
|
| 2248 | + | self.write_commit( |
|
| 2249 | + | &mut stream, |
|
| 2250 | + | "refs/oxy/export", |
|
| 2251 | + | mark, |
|
| 2252 | + | &self.git_author_key(&meta.author), |
|
| 2253 | + | meta.hlc.wall_ms / 1000, |
|
| 2254 | + | &message, |
|
| 2255 | + | &parents, |
|
| 2256 | + | &snapshot, |
|
| 2257 | + | )?; |
|
| 2258 | + | marks.insert(*h, mark); |
|
| 2259 | + | report.commits += 1; |
|
| 2260 | + | } |
|
| 2261 | + | ||
| 2262 | + | let mut wanted: HashSet<String> = HashSet::new(); |
|
| 2263 | + | for (name, heads) in &refs { |
|
| 2264 | + | let mut heads = heads.clone(); |
|
| 2265 | + | heads.sort(); |
|
| 2266 | + | heads.dedup(); |
|
| 2267 | + | let target = match heads.as_slice() { |
|
| 2268 | + | [] => continue, |
|
| 2269 | + | [one] => format!(":{}", marks[one]), |
|
| 2270 | + | many => { |
|
| 2271 | + | // A merge of the heads, named by them so it is made once. |
|
| 2272 | + | let key = hash_object( |
|
| 2273 | + | ObjectType::Chunk, |
|
| 2274 | + | many.iter() |
|
| 2275 | + | .map(|h| h.to_hex()) |
|
| 2276 | + | .collect::<Vec<_>>() |
|
| 2277 | + | .join(" ") |
|
| 2278 | + | .as_bytes(), |
|
| 2279 | + | ); |
|
| 2280 | + | if let Some(m) = marks.get(&key) { |
|
| 2281 | + | format!(":{m}") |
|
| 2282 | + | } else { |
|
| 2283 | + | let snapshot = self.load_state(many)?.materialize(); |
|
| 2284 | + | let newest = many.iter().map(|h| metas[h].hlc.wall_ms).max().unwrap_or(0); |
|
| 2285 | + | let mut message = format!( |
|
| 2286 | + | "Merge of {} heads\n\nOxy-Heads: {}\n", |
|
| 2287 | + | many.len(), |
|
| 2288 | + | many.iter() |
|
| 2289 | + | .map(|h| h.to_hex()) |
|
| 2290 | + | .collect::<Vec<_>>() |
|
| 2291 | + | .join(" ") |
|
| 2292 | + | ); |
|
| 2293 | + | if snapshot.is_conflicted() { |
|
| 2294 | + | message.push_str("Oxy-Conflicted: true\n"); |
|
| 2295 | + | } |
|
| 2296 | + | let parents: Vec<String> = |
|
| 2297 | + | many.iter().map(|h| format!(":{}", marks[h])).collect(); |
|
| 2298 | + | let mark = next; |
|
| 2299 | + | next += 1; |
|
| 2300 | + | self.write_commit( |
|
| 2301 | + | &mut stream, |
|
| 2302 | + | "refs/oxy/export", |
|
| 2303 | + | mark, |
|
| 2304 | + | "oxy <oxy@localhost>", |
|
| 2305 | + | newest / 1000, |
|
| 2306 | + | &message, |
|
| 2307 | + | &parents, |
|
| 2308 | + | &snapshot, |
|
| 2309 | + | )?; |
|
| 2310 | + | marks.insert(key, mark); |
|
| 2311 | + | format!(":{mark}") |
|
| 2312 | + | } |
|
| 2313 | + | } |
|
| 2314 | + | }; |
|
| 2315 | + | stream.extend_from_slice(format!("reset {name}\nfrom {target}\n\n").as_bytes()); |
|
| 2316 | + | wanted.insert(name.clone()); |
|
| 2317 | + | if name.starts_with("refs/tags/") { |
|
| 2318 | + | report.tags += 1; |
|
| 2319 | + | } else { |
|
| 2320 | + | report.branches += 1; |
|
| 2321 | + | } |
|
| 2322 | + | } |
|
| 2323 | + | stream.extend_from_slice(b"done\n"); |
|
| 2324 | + | run_fast_import(git_dir, &stream, &git_marks)?; |
|
| 2325 | + | let text: String = marks |
|
| 2326 | + | .iter() |
|
| 2327 | + | .map(|(h, m)| format!("{} {m}\n", h.to_hex())) |
|
| 2328 | + | .collect(); |
|
| 2329 | + | write_atomic(&graph_marks, text.as_bytes())?; |
|
| 2330 | + | let _ = git(git_dir, &["update-ref", "-d", "refs/oxy/export"]); |
|
| 2331 | + | // Branches and tags of bookmarks and tags that are gone. |
|
| 2332 | + | let listed = git( |
|
| 2333 | + | git_dir, |
|
| 2334 | + | &[ |
|
| 2335 | + | "for-each-ref", |
|
| 2336 | + | "--format=%(refname)", |
|
| 2337 | + | "refs/heads", |
|
| 2338 | + | "refs/tags", |
|
| 2339 | + | ], |
|
| 2340 | + | )?; |
|
| 2341 | + | for r in listed |
|
| 2342 | + | .lines() |
|
| 2343 | + | .map(str::trim) |
|
| 2344 | + | .filter(|r| !r.is_empty() && !wanted.contains(*r)) |
|
| 2345 | + | { |
|
| 2346 | + | git(git_dir, &["update-ref", "-d", r])?; |
|
| 2347 | + | } |
|
| 2348 | + | // HEAD names the main bookmark when there is one, so a clone checks it out. |
|
| 2349 | + | let main = ["main", "master", "trunk", "develop"] |
|
| 2350 | + | .into_iter() |
|
| 2351 | + | .find(|b| wanted.contains(&format!("refs/heads/{b}"))); |
|
| 2352 | + | if let Some(b) = main { |
|
| 2353 | + | git( |
|
| 2354 | + | git_dir, |
|
| 2355 | + | &["symbolic-ref", "HEAD", &format!("refs/heads/{b}")], |
|
| 2356 | + | )?; |
|
| 2357 | + | } |
|
| 2358 | + | report.skipped_subrepos = self.git_export_skipped.replace(0); |
|
| 2359 | + | Ok(report) |
|
| 2360 | + | } |
|
| 2361 | + | ||
| 2362 | + | /// `Name <email>` for a Git commit, from the author's identity. |
|
| 2363 | + | fn git_author_key(&self, key: &KeyId) -> String { |
|
| 2364 | + | match self.store.get_identity(key) { |
|
| 2365 | + | Ok(id) if !id.name.is_empty() => format!( |
|
| 2366 | + | "{} <{}>", |
|
| 2367 | + | id.name.replace(['<', '>', '\n'], " "), |
|
| 2368 | + | id.email.replace(['<', '>', '\n', ' '], "") |
|
| 2369 | + | ), |
|
| 2370 | + | _ => "oxy <oxy@localhost>".to_string(), |
|
| 2371 | + | } |
|
| 2372 | + | } |
|
| 2373 | + | } |
|
| 2374 | + | ||
| 2375 | + | /// Feed a fast-import stream to `git fast-import` in `git_dir`, keeping its |
|
| 2376 | + | /// marks in `git_marks` so a later run can refer to commits it wrote. |
|
| 2377 | + | fn run_fast_import(git_dir: &Path, stream: &[u8], git_marks: &Path) -> Result<()> { |
|
| 2378 | + | let mut cmd = Command::new("git"); |
|
| 2379 | + | // `--force`: a ref may move to an unrelated commit (a bookmark rewritten). |
|
| 2380 | + | cmd.arg("-C") |
|
| 2381 | + | .arg(git_dir) |
|
| 2382 | + | .args(["fast-import", "--quiet", "--done", "--force"]) |
|
| 2383 | + | .arg(format!("--export-marks={}", git_marks.display())); |
|
| 2384 | + | if git_marks.exists() { |
|
| 2385 | + | cmd.arg(format!("--import-marks={}", git_marks.display())); |
|
| 2386 | + | } |
|
| 2387 | + | let mut child = cmd |
|
| 2388 | + | .stdin(Stdio::piped()) |
|
| 2389 | + | .stdout(Stdio::null()) |
|
| 2390 | + | .stderr(Stdio::piped()) |
|
| 2391 | + | .spawn() |
|
| 2392 | + | .map_err(|e| RepoError::Msg(format!("running git fast-import: {e}")))?; |
|
| 2393 | + | child.stdin.take().unwrap().write_all(stream)?; |
|
| 2394 | + | let out = child.wait_with_output()?; |
|
| 2395 | + | if !out.status.success() { |
|
| 2396 | + | return fail(format!( |
|
| 2397 | + | "git fast-import failed: {}", |
|
| 2398 | + | String::from_utf8_lossy(&out.stderr).trim() |
|
| 2399 | + | )); |
|
| 2400 | + | } |
|
| 2401 | + | Ok(()) |
|
| 2112 | 2402 | } |
modified crates/oxy-core/src/repo/tests.rs +153 −0
| … | 10804 unchanged lines |
||
| 10805 | 10805 | "a repository's pin wins" |
|
| 10806 | 10806 | ); |
|
| 10807 | 10807 | } |
|
| 10808 | + | ||
| 10809 | + | #[test] |
|
| 10810 | + | fn git_export_graph_is_deterministic_and_merges_heads() { |
|
| 10811 | + | hermetic(); |
|
| 10812 | + | if std::process::Command::new("git") |
|
| 10813 | + | .arg("--version") |
|
| 10814 | + | .output() |
|
| 10815 | + | .is_err() |
|
| 10816 | + | { |
|
| 10817 | + | eprintln!("git not installed; skipping"); |
|
| 10818 | + | return; |
|
| 10819 | + | } |
|
| 10820 | + | let dir = tempfile::tempdir().unwrap(); |
|
| 10821 | + | let root = dir.path(); |
|
| 10822 | + | let mut repo = Repo::init(&root.join("r")) |
|
| 10823 | + | .unwrap() |
|
| 10824 | + | .with_home(root.join("home")); |
|
| 10825 | + | repo.create_identity("Ada Lovelace", "[email protected]", IdentityScope::User) |
|
| 10826 | + | .unwrap(); |
|
| 10827 | + | let r = root.join("r"); |
|
| 10828 | + | write(&r, "README.md", b"hello\n"); |
|
| 10829 | + | let h1 = repo.commit("first", &[]).unwrap(); |
|
| 10830 | + | write(&r, "README.md", b"hello\nworld\n"); |
|
| 10831 | + | write(&r, "src/main.rs", b"fn main() {}\n"); |
|
| 10832 | + | let h2 = repo.commit("second", &[]).unwrap(); |
|
| 10833 | + | repo.tag_create("v1", &[h2], "the first release").unwrap(); |
|
| 10834 | + | // An independent change: main gets two heads. |
|
| 10835 | + | repo.checkout(&[h1], false, "checkout").unwrap(); |
|
| 10836 | + | write(&r, "other.txt", b"o\n"); |
|
| 10837 | + | let h3 = repo.commit("third", &[]).unwrap(); |
|
| 10838 | + | repo.bookmark_create("gone", Some(&[h1])).unwrap(); |
|
| 10839 | + | ||
| 10840 | + | let git = |git_dir: &Path, args: &[&str]| -> String { |
|
| 10841 | + | let out = std::process::Command::new("git") |
|
| 10842 | + | .arg("-C") |
|
| 10843 | + | .arg(git_dir) |
|
| 10844 | + | .args(args) |
|
| 10845 | + | .output() |
|
| 10846 | + | .unwrap(); |
|
| 10847 | + | assert!( |
|
| 10848 | + | out.status.success(), |
|
| 10849 | + | "git {args:?}: {}", |
|
| 10850 | + | String::from_utf8_lossy(&out.stderr) |
|
| 10851 | + | ); |
|
| 10852 | + | String::from_utf8_lossy(&out.stdout).trim().to_string() |
|
| 10853 | + | }; |
|
| 10854 | + | let a = root.join("a.git"); |
|
| 10855 | + | let rep = repo.git_export_graph(&a).unwrap(); |
|
| 10856 | + | assert_eq!((rep.commits, rep.tags), (3, 1), "{rep:?}"); |
|
| 10857 | + | assert_eq!(rep.branches, 2, "main and gone: {rep:?}"); |
|
| 10858 | + | ||
| 10859 | + | // Each change is a commit whose parents are exactly its dependencies' |
|
| 10860 | + | // commits (Oxygen's dependencies, not the order changes were made in). |
|
| 10861 | + | let by_hash = |hash: ChangeHash| { |
|
| 10862 | + | git( |
|
| 10863 | + | &a, |
|
| 10864 | + | &[ |
|
| 10865 | + | "log", |
|
| 10866 | + | "--all", |
|
| 10867 | + | "--format=%H", |
|
| 10868 | + | &format!("--grep=Oxy-Hash: {}", hash.to_hex()), |
|
| 10869 | + | ], |
|
| 10870 | + | ) |
|
| 10871 | + | }; |
|
| 10872 | + | let (c1, c2, c3) = (by_hash(h1), by_hash(h2), by_hash(h3)); |
|
| 10873 | + | for (h, c) in [(h1, &c1), (h2, &c2), (h3, &c3)] { |
|
| 10874 | + | let mut want: Vec<String> = repo |
|
| 10875 | + | .store() |
|
| 10876 | + | .get_change_meta(&h) |
|
| 10877 | + | .unwrap() |
|
| 10878 | + | .deps |
|
| 10879 | + | .iter() |
|
| 10880 | + | .map(|d| by_hash(*d)) |
|
| 10881 | + | .collect(); |
|
| 10882 | + | want.sort(); |
|
| 10883 | + | let mut have: Vec<String> = git(&a, &["rev-list", "--parents", "-1", c]) |
|
| 10884 | + | .split(' ') |
|
| 10885 | + | .skip(1) |
|
| 10886 | + | .map(String::from) |
|
| 10887 | + | .collect(); |
|
| 10888 | + | have.sort(); |
|
| 10889 | + | assert_eq!(have, want, "{h:?}'s commit's parents"); |
|
| 10890 | + | } |
|
| 10891 | + | assert!( |
|
| 10892 | + | !repo.store().get_change_meta(&h2).unwrap().deps.is_empty(), |
|
| 10893 | + | "second edits first's README" |
|
| 10894 | + | ); |
|
| 10895 | + | assert_eq!( |
|
| 10896 | + | git(&a, &["show", &format!("{c2}:src/main.rs")]), |
|
| 10897 | + | "fn main() {}" |
|
| 10898 | + | ); |
|
| 10899 | + | assert!( |
|
| 10900 | + | git(&a, &["ls-tree", "--name-only", &c3]) |
|
| 10901 | + | .lines() |
|
| 10902 | + | .all(|l| l != "src"), |
|
| 10903 | + | "a change's tree is its own closure, not the bookmark's" |
|
| 10904 | + | ); |
|
| 10905 | + | assert_eq!( |
|
| 10906 | + | git(&a, &["log", "-1", "--format=%an <%ae>", &c1]), |
|
| 10907 | + | "Ada Lovelace <[email protected]>" |
|
| 10908 | + | ); |
|
| 10909 | + | ||
| 10910 | + | // main has two heads: a merge of both, whose tree is the union. |
|
| 10911 | + | let main = git(&a, &["rev-parse", "main"]); |
|
| 10912 | + | let mut parents: Vec<String> = git(&a, &["rev-list", "--parents", "-1", "main"]) |
|
| 10913 | + | .split(' ') |
|
| 10914 | + | .skip(1) |
|
| 10915 | + | .map(String::from) |
|
| 10916 | + | .collect(); |
|
| 10917 | + | parents.sort(); |
|
| 10918 | + | let mut want = vec![c2.clone(), c3.clone()]; |
|
| 10919 | + | want.sort(); |
|
| 10920 | + | assert_eq!(parents, want, "the merge's parents are the heads"); |
|
| 10921 | + | assert_eq!( |
|
| 10922 | + | git(&a, &["ls-tree", "--name-only", "main"]), |
|
| 10923 | + | "README.md\nother.txt\nsrc" |
|
| 10924 | + | ); |
|
| 10925 | + | assert!(git(&a, &["log", "-1", "--format=%B", "main"]).contains("Merge of 2 heads")); |
|
| 10926 | + | assert_eq!( |
|
| 10927 | + | git(&a, &["rev-parse", "v1^{commit}"]), |
|
| 10928 | + | c2, |
|
| 10929 | + | "the tag is its head's commit" |
|
| 10930 | + | ); |
|
| 10931 | + | assert_eq!(git(&a, &["symbolic-ref", "HEAD"]), "refs/heads/main"); |
|
| 10932 | + | ||
| 10933 | + | // Again: nothing new. A bookmark deleted takes its branch with it. |
|
| 10934 | + | repo.bookmark_delete("gone").unwrap(); |
|
| 10935 | + | let rep = repo.git_export_graph(&a).unwrap(); |
|
| 10936 | + | assert_eq!(rep.commits, 0, "{rep:?}"); |
|
| 10937 | + | assert!( |
|
| 10938 | + | git(&a, &["for-each-ref", "--format=%(refname)", "refs/heads"]) |
|
| 10939 | + | .lines() |
|
| 10940 | + | .all(|r| r != "refs/heads/gone") |
|
| 10941 | + | ); |
|
| 10942 | + | assert_eq!( |
|
| 10943 | + | git(&a, &["rev-parse", "main"]), |
|
| 10944 | + | main, |
|
| 10945 | + | "the merge is made once" |
|
| 10946 | + | ); |
|
| 10947 | + | ||
| 10948 | + | // The same changes elsewhere give the same commits. |
|
| 10949 | + | let other = Repo::clone(r.to_str().unwrap(), &root.join("clone")) |
|
| 10950 | + | .unwrap() |
|
| 10951 | + | .with_home(root.join("home")); |
|
| 10952 | + | let b = root.join("b.git"); |
|
| 10953 | + | other.git_export_graph(&b).unwrap(); |
|
| 10954 | + | assert_eq!( |
|
| 10955 | + | git(&b, &["rev-parse", "main"]), |
|
| 10956 | + | main, |
|
| 10957 | + | "deterministic across repositories" |
|
| 10958 | + | ); |
|
| 10959 | + | assert_eq!(git(&b, &["rev-parse", "v1^{commit}"]), c2); |
|
| 10960 | + | } |
modified docs/spec.md +1 −0
| … | 1010 unchanged lines |
||
| 1011 | 1011 | - **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. |
|
| 1012 | 1012 | - **Submodules.** A Git gitlink entry (mode `160000`) imports as a `subrepo` node with a Git pin (§7.8) and the matching `.gitmodules` entry becomes a `.oxymodules` entry with `branch` set to the submodule's configured branch when there is one. Export writes a Git pin back as a gitlink, keeps a tracked `.gitmodules` as is, and otherwise derives `.gitmodules` from `.oxymodules`; an Oxygen pin has no Git form and is omitted from the exported tree, which the export report counts. |
|
| 1013 | 1013 | - **Merges of opaque files.** When the union of a merge commit's parents holds several live blobs for a file or submodule, the merge's change replaces each of them with the commit's blob and is a resolution: Git's merge is the authority, unlike a working copy, where a candidate on disk is not yet a decision. |
|
| 1014 | + | - **Export by change graph.** For a server serving Git clients (a hub's Git view), `git_export_graph` exports every bookmark and tag from the changes alone, so the same changes give the same commits wherever it runs: each change is a commit whose tree is the state of the change and its dependency closure and whose parents are its dependencies' commits (a change that depends on nothing is a root), with the change's author, time, message and `Change-Id:`/`Oxy-Hash:` trailers; a bookmark with one head is a branch at its commit, with several a merge of their commits (the bookmark's state, at the newest head's time, `Oxy-Heads:`); tags likewise. Marks make it incremental; branches of deleted bookmarks are removed. |
|
| 1014 | 1015 | - **Round trip.** Import followed by export MUST reproduce every tree hash. Export followed by import MUST reproduce every ChangeId. `oxy git verify GIT_DIR` checks an import commit by commit: for each commit, the state formed by the changes imported for it and all its ancestors must materialize to exactly that commit's tree (content, executable bits, symlink targets, submodule pins; `.oxymodules` is allowed beside submodules). `--last N` rebuilds only the last N commits, following the others, to look again at where an import went wrong. |
|
| 1015 | 1016 | ||
| 1016 | 1017 | ## 24. Command-line interface |
|
| … | 179 unchanged lines |