OXYGENHUB

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