git import and fetch --claim: author your own commits with your key d6e0d5b8b339
An imported change had the zero author key whoever wrote it, so a person importing their own history found it credited to nobody. With git.claim (set by --claim on oxy git import and oxy git fetch), a commit whose Git author email is the importing identity's email, compared without case, gets that identity's key as author, and is sealed by it on push like any of its changes; other commits keep the zero key and their Git-Author trailer. A claim with no identity is refused with what to do. Spec §23. closes #97
- Author
- Andy McCall @andymccall <[email protected]>
- When
- · 2026-10-10 20:03 UTC
- Signature
- ✔ signed by Andy McCall
- Hash
d6e0d5b8b339aaae38f3ceb1c1044e5f7f0c71c6f87db2a94b67410783e81403- Depends on
- 170bb436e1d5 2306a4d24779 30ea9c1e70fa 42ccf504aa50 54e2e6309aa3 58dfe0e134eb 62794ecc7f74 6d3eefbce2f0 7421309f5372 75167672ebcd 79ab8528ff62 8fd78eb52ad2 982864068a59 a87c7b9d563a aedfe465b611 ba160f60219e c3069537d7c5 cb8b8082fa73 dfb23a3a8cfa fce44ae9ad29 ff0af1ee299d
- Depended on by
- c397a8c36a9f 570e35310f31 d3b270909c78 34fad4a92451
- In bookmarks
- 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.10.0 v0.11.0 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
- 20 atoms, 0 tree operations, 7 files
Browse the tree at this change
modified CHANGELOG.md +2 −0
| … | 1 unchanged lines |
||
| 2 | 2 | ||
| 3 | 3 | ## Unreleased |
|
| 4 | 4 | ||
| 5 | + | - **Claim your own commits in a Git import (#97).** `oxy git import --claim` and `oxy git fetch --claim` set `git.claim`: a commit whose Git author email is your identity's email is authored with your key (and sealed by you on push), so a forge credits it to your account; anyone else's commits keep the zero key, as before. |
|
| 6 | + | ||
| 5 | 7 | - **Git imports of long histories much faster (#96).** Rendering a file asks whether the changes that deleted and inserted neighbouring lines depend on each other; in a deep history each answer was a walk through thousands of changes, asked again for every line, under a lock every rendering thread queued on. Answers are now remembered for the file being rendered, the shared memos are read without excluding other threads, and a merge renders its files on every core. On VS Code past 66 000 commits, 500 commits imported in 261 s rather than 734 s, with the same history. |
|
| 6 | 8 | ||
| 7 | 9 | - **A long push goes in parts (#95).** `oxy push` sent a whole history in one request, held in memory on both sides; the full VS Code import would have passed a hub's 4 GiB limit. It now sends parts of about `push.batch-bytes` (default 256 MiB) in dependency order, objects alone, and the last part moves the bookmark; the spinner says which part is going. A server stores a part and reports it as `objects`. |
|
| … | 160 unchanged lines |
modified crates/oxy-core/src/repo/config.rs +5 −0
| … | 30 unchanged lines |
||
| 31 | 31 | /// `core.remote`: the remote `pull`, `fetch` and `push` use when none is |
|
| 32 | 32 | /// named, for a repository whose remotes include no `origin`. |
|
| 33 | 33 | pub default_remote: Option<String>, |
|
| 34 | + | /// `git.claim`: a Git import authors the commits whose Git author email |
|
| 35 | + | /// is this repository's identity's with its key (oxygen#97). |
|
| 36 | + | pub git_claim: bool, |
|
| 34 | 37 | /// `core.op-retention`: days for which operations keep their states reachable (spec §12.1, §21). |
|
| 35 | 38 | pub op_retention_days: u64, |
|
| 36 | 39 | /// `core.gc-grace`: days an unreachable object survives, protecting concurrent writers (spec §21). |
|
| … | 44 unchanged lines |
||
| 81 | 84 | remote_tokens: BTreeMap::new(), |
|
| 82 | 85 | remote_cas: BTreeMap::new(), |
|
| 83 | 86 | default_remote: None, |
|
| 87 | + | git_claim: false, |
|
| 84 | 88 | op_retention_days: 90, |
|
| 85 | 89 | gc_grace_days: 14, |
|
| 86 | 90 | checkpoint_interval: 256, |
|
| … | 89 unchanged lines |
||
| 176 | 180 | self.default_remote = (!value.is_empty()).then(|| value.to_string()); |
|
| 177 | 181 | } |
|
| 178 | 182 | "hooks.semantic-deps" => self.semantic_deps = matches!(value, "true" | "1" | "yes"), |
|
| 183 | + | "git.claim" => self.git_claim = matches!(value, "true" | "1" | "yes"), |
|
| 179 | 184 | "filter.paths" => { |
|
| 180 | 185 | self.sparse_paths = value |
|
| 181 | 186 | .split(',') |
|
| … | 147 unchanged lines |
modified crates/oxy-core/src/repo/git.rs +22 −1
| … | 184 unchanged lines |
||
| 185 | 185 | } |
|
| 186 | 186 | } |
|
| 187 | 187 | ||
| 188 | + | /// The email of a Git author `Name <email>`, lowercased. |
|
| 189 | + | fn git_email(author: &str) -> Option<String> { |
|
| 190 | + | let (_, rest) = author.rsplit_once('<')?; |
|
| 191 | + | let email = rest.strip_suffix('>')?.trim(); |
|
| 192 | + | (!email.is_empty()).then(|| email.to_lowercase()) |
|
| 193 | + | } |
|
| 194 | + | ||
| 188 | 195 | /// One commit as `git log` describes it. |
|
| 189 | 196 | struct GitCommit { |
|
| 190 | 197 | sha: String, |
|
| … | 753 unchanged lines |
||
| 944 | 951 | // builds them from the whole tree, for comparing. |
|
| 945 | 952 | let partial_merges = std::env::var_os("OXY_IMPORT_WHOLE_MERGES").is_none_or(|v| v == "0"); |
|
| 946 | 953 | let total = commits.len(); |
|
| 954 | + | // The importing identity's own commits, claimed (`git.claim`, |
|
| 955 | + | // oxygen#97): a commit whose Git author email is its email gets |
|
| 956 | + | // its key as author; anyone else's keeps the zero key. |
|
| 957 | + | let claim: Option<(KeyId, String)> = if self.config.git_claim { |
|
| 958 | + | let Some(key) = self.config.author_key else { |
|
| 959 | + | return fail("git.claim authors your own commits with your key, and there is none: run `oxy identity create` first, or `oxy config git.claim false`"); |
|
| 960 | + | }; |
|
| 961 | + | Some((key, self.identity(&key)?.email.to_lowercase())) |
|
| 962 | + | } else { |
|
| 963 | + | None |
|
| 964 | + | }; |
|
| 947 | 965 | for (index, gc) in commits.iter().enumerate() { |
|
| 948 | 966 | if known.imported.contains_key(&gc.sha) || known.exported.contains_key(&gc.sha) { |
|
| 949 | 967 | continue; |
|
| … | 429 unchanged lines |
||
| 1379 | 1397 | version: crate::objects::CHANGE_VERSION, |
|
| 1380 | 1398 | change_id: change_id_for(&gc.sha, &gc.message), |
|
| 1381 | 1399 | deps: vec![], |
|
| 1382 | - | author: ObjectRef::ZERO, |
|
| 1400 | + | author: claim |
|
| 1401 | + | .as_ref() |
|
| 1402 | + | .filter(|(_, email)| git_email(&gc.author).is_some_and(|e| e == *email)) |
|
| 1403 | + | .map_or(ObjectRef::ZERO, |(key, _)| *key), |
|
| 1383 | 1404 | hlc: Hlc { |
|
| 1384 | 1405 | wall_ms: gc.author_secs * 1000, |
|
| 1385 | 1406 | logical: 0, |
|
| … | 690 unchanged lines |
modified crates/oxy-core/src/repo/tests.rs +74 −0
| … | 8167 unchanged lines |
||
| 8168 | 8168 | assert_eq!(got.len(), 1); |
|
| 8169 | 8169 | assert!(got[0].ok && got[0].name == OBJECTS_RESULT, "{got:?}"); |
|
| 8170 | 8170 | } |
|
| 8171 | + | ||
| 8172 | + | /// oxygen#97: with `git.claim`, an import authors the importing identity's |
|
| 8173 | + | /// own commits (Git author email = its email, any case) with its key, and |
|
| 8174 | + | /// leaves anyone else's with the zero key; the history verifies as before. |
|
| 8175 | + | #[test] |
|
| 8176 | + | fn git_import_claims_your_own_commits() { |
|
| 8177 | + | hermetic(); |
|
| 8178 | + | if std::process::Command::new("git") |
|
| 8179 | + | .arg("--version") |
|
| 8180 | + | .output() |
|
| 8181 | + | .is_err() |
|
| 8182 | + | { |
|
| 8183 | + | eprintln!("git not installed; skipping"); |
|
| 8184 | + | return; |
|
| 8185 | + | } |
|
| 8186 | + | let dir = tempfile::tempdir().unwrap(); |
|
| 8187 | + | let root = dir.path(); |
|
| 8188 | + | let g = root.join("src.git"); |
|
| 8189 | + | fs::create_dir_all(&g).unwrap(); |
|
| 8190 | + | let git = |who: &str, args: &[&str]| { |
|
| 8191 | + | let (name, email) = who.split_once(' ').unwrap(); |
|
| 8192 | + | let out = std::process::Command::new("git") |
|
| 8193 | + | .arg("-C") |
|
| 8194 | + | .arg(&g) |
|
| 8195 | + | .args([ |
|
| 8196 | + | "-c", |
|
| 8197 | + | &format!("user.name={name}"), |
|
| 8198 | + | "-c", |
|
| 8199 | + | &format!("user.email={email}"), |
|
| 8200 | + | "-c", |
|
| 8201 | + | "commit.gpgsign=false", |
|
| 8202 | + | ]) |
|
| 8203 | + | .args(args) |
|
| 8204 | + | .output() |
|
| 8205 | + | .unwrap(); |
|
| 8206 | + | assert!( |
|
| 8207 | + | out.status.success(), |
|
| 8208 | + | "git {args:?}: {}", |
|
| 8209 | + | String::from_utf8_lossy(&out.stderr) |
|
| 8210 | + | ); |
|
| 8211 | + | }; |
|
| 8212 | + | git("Grace [email protected]", &["init", "-q", "-b", "main"]); |
|
| 8213 | + | write(&g, "a.txt", b"a\n"); |
|
| 8214 | + | git("Grace [email protected]", &["add", "."]); |
|
| 8215 | + | git("Grace [email protected]", &["commit", "-qm", "mine"]); |
|
| 8216 | + | write(&g, "b.txt", b"b\n"); |
|
| 8217 | + | git("Ada [email protected]", &["add", "."]); |
|
| 8218 | + | git("Ada [email protected]", &["commit", "-qm", "someone else's"]); |
|
| 8219 | + | ||
| 8220 | + | let mut repo = Repo::init(&root.join("o")).unwrap(); |
|
| 8221 | + | let (key, _) = repo |
|
| 8222 | + | .create_identity("Grace", "[email protected]", IdentityScope::Repo) |
|
| 8223 | + | .unwrap(); |
|
| 8224 | + | repo.config.set("git.claim", "true").unwrap(); |
|
| 8225 | + | repo.save_config().unwrap(); |
|
| 8226 | + | let repo = Repo::open(&root.join("o")).unwrap(); |
|
| 8227 | + | repo.import_git(&g, "main").unwrap(); |
|
| 8228 | + | let v = repo.verify_git_import(&g, "main").unwrap(); |
|
| 8229 | + | assert!(v.mismatches.is_empty(), "{:?}", v.mismatches); |
|
| 8230 | + | let heads = repo.bookmark_heads().unwrap(); |
|
| 8231 | + | let by_message: std::collections::HashMap<String, KeyId> = repo |
|
| 8232 | + | .log_state(&heads) |
|
| 8233 | + | .unwrap() |
|
| 8234 | + | .into_iter() |
|
| 8235 | + | .map(|e| { |
|
| 8236 | + | ( |
|
| 8237 | + | e.change.message.lines().next().unwrap_or("").to_string(), |
|
| 8238 | + | e.change.author, |
|
| 8239 | + | ) |
|
| 8240 | + | }) |
|
| 8241 | + | .collect(); |
|
| 8242 | + | assert_eq!(by_message["mine"], key, "her own commit, any case of email"); |
|
| 8243 | + | assert_eq!(by_message["someone else's"], ObjectRef::ZERO, "not hers"); |
|
| 8244 | + | } |
modified docs/spec.md +1 −1
| … | 866 unchanged lines |
||
| 867 | 867 | ||
| 868 | 868 | `oxy git import <repo>` and `oxy git export <repo>` provide a lossless-enough bridge; `oxy git fetch/push` operate against a Git remote directly. |
|
| 869 | 869 | ||
| 870 | - | - **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. New files' and directories' NodeIds are derived too, as BLAKE3-128 of the commit hash, the node kind and the path, so importing the same history twice, anywhere, gives the same changes. 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. |
|
| 870 | + | - **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. The exception is the importing identity's own history: with `git.claim` set (`oxy git import --claim`, `oxy git fetch --claim`), a commit whose Git author email is that identity's email, compared without case, gets its key as author, and is sealed by it on push like any of its changes; every other commit keeps the zero key. 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. New files' and directories' NodeIds are derived too, as BLAKE3-128 of the commit hash, the node kind and the path, so importing the same history twice, anywhere, gives the same changes. 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. |
|
| 871 | 871 | - **Directories.** Git gives a directory no identity: its path is all it is. An import therefore never makes a second node for a directory path: when a commit needs a directory where the parent's state has none, and the parent's history has a directory last placed at that (parent, name) and now placed nowhere, the change brings that node back with a `Move` from its last placement (the newest by operation key when there are several). Otherwise lines of history that placed files in the directory before it was deleted and after it came back would place them in two different directories, a conflict Git never had. Likewise a node the commit keeps is in exactly the one place its tree names: any other live placement it has in the parents' union (one left under a directory another line removed is out of sight, and no diff of the tree would reach it) is retired by the commit's change, and so is every live placement of a node the union cannot show at all (an orphan, whose only placements are under removed directories): Git's tree has no path for it. |
|
| 872 | 872 | - **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. |
|
| 873 | 873 | - **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. |
|
| … | 181 unchanged lines |
modified src/cli/mod.rs +10 −3
| … | 928 unchanged lines |
||
| 929 | 929 | /// Branch to fetch |
|
| 930 | 930 | #[arg(long, default_value = "main")] |
|
| 931 | 931 | branch: String, |
|
| 932 | + | /// Author your own commits (Git author email = your identity's) with your key; sets git.claim |
|
| 933 | + | #[arg(long)] |
|
| 934 | + | claim: bool, |
|
| 932 | 935 | }, |
|
| 933 | 936 | /// Push the current bookmark to a branch of a Git remote (fetch first) |
|
| 934 | 937 | Push { |
|
| … | 22 unchanged lines |
||
| 957 | 960 | /// Branch to import |
|
| 958 | 961 | #[arg(long, default_value = "main")] |
|
| 959 | 962 | branch: String, |
|
| 963 | + | /// Author your own commits (Git author email = your identity's) with your key; sets git.claim |
|
| 964 | + | #[arg(long)] |
|
| 965 | + | claim: bool, |
|
| 960 | 966 | }, |
|
| 961 | 967 | } |
|
| 962 | 968 | ||
| … | 377 unchanged lines |
||
| 1340 | 1346 | cmd: GitCmd::Export { git_dir, branch }, |
|
| 1341 | 1347 | } => sync::git_export(&git_dir, branch.as_deref()), |
|
| 1342 | 1348 | Cmd::Git { |
|
| 1343 | - | cmd: GitCmd::Fetch { url, branch }, |
|
| 1344 | - | } => sync::git_fetch(&url, &branch), |
|
| 1349 | + | cmd: GitCmd::Fetch { url, branch, claim }, |
|
| 1350 | + | } => sync::git_fetch(&url, &branch, claim), |
|
| 1345 | 1351 | Cmd::Git { |
|
| 1346 | 1352 | cmd: GitCmd::Push { url, branch }, |
|
| 1347 | 1353 | } => sync::git_push(&url, &branch), |
|
| … | 6 unchanged lines |
||
| 1354 | 1360 | git_dir, |
|
| 1355 | 1361 | dest, |
|
| 1356 | 1362 | branch, |
|
| 1363 | + | claim, |
|
| 1357 | 1364 | }, |
|
| 1358 | - | } => sync::git_import(&git_dir, &dest, &branch), |
|
| 1365 | + | } => sync::git_import(&git_dir, &dest, &branch, claim), |
|
| 1359 | 1366 | Cmd::Git { |
|
| 1360 | 1367 | cmd: GitCmd::Verify { git_dir, branch }, |
|
| 1361 | 1368 | } => sync::git_verify(&git_dir, &branch), |
|
| … | 79 unchanged lines |
modified src/cli/sync.rs +23 −4
| … | 758 unchanged lines |
||
| 759 | 759 | Ok(0) |
|
| 760 | 760 | } |
|
| 761 | 761 | ||
| 762 | - | pub fn git_fetch(url: &str, branch: &str) -> Result<i32> { |
|
| 763 | - | let repo = open()?; |
|
| 762 | + | /// `--claim` (oxygen#97): record `git.claim`, so this import and every |
|
| 763 | + | /// later fetch author the identity's own commits with its key. |
|
| 764 | + | fn claim_commits(repo: &mut Repo) -> Result<()> { |
|
| 765 | + | repo.config.set("git.claim", "true")?; |
|
| 766 | + | repo.save_config()?; |
|
| 767 | + | Ok(()) |
|
| 768 | + | } |
|
| 769 | + | ||
| 770 | + | pub fn git_fetch(url: &str, branch: &str, claim: bool) -> Result<i32> { |
|
| 771 | + | let mut repo = open()?; |
|
| 772 | + | if claim { |
|
| 773 | + | claim_commits(&mut repo)?; |
|
| 774 | + | } |
|
| 764 | 775 | let sp = ui::spinner(&format!("Fetching {url} {branch}…")); |
|
| 765 | 776 | let r = repo.git_fetch(url, branch)?; |
|
| 766 | 777 | sp.clear(); |
|
| … | 93 unchanged lines |
||
| 860 | 871 | } |
|
| 861 | 872 | } |
|
| 862 | 873 | ||
| 863 | - | pub fn git_import(git_dir: &std::path::Path, dest: &std::path::Path, branch: &str) -> Result<i32> { |
|
| 864 | - | let repo = Repo::init(dest)?; |
|
| 874 | + | pub fn git_import( |
|
| 875 | + | git_dir: &std::path::Path, |
|
| 876 | + | dest: &std::path::Path, |
|
| 877 | + | branch: &str, |
|
| 878 | + | claim: bool, |
|
| 879 | + | ) -> Result<i32> { |
|
| 880 | + | let mut repo = Repo::init(dest)?; |
|
| 881 | + | if claim { |
|
| 882 | + | claim_commits(&mut repo)?; |
|
| 883 | + | } |
|
| 865 | 884 | let started = std::time::Instant::now(); |
|
| 866 | 885 | let sp = ui::spinner(&format!("Importing {} {branch}…", git_dir.display())); |
|
| 867 | 886 | let r = repo.import_git_with(git_dir, branch, &mut |where_| sp.set(where_))?; |
|
| … | 253 unchanged lines |