OXYGENHUB

Git import: a large merge's already-built file no longer renders empty 33be24095b4b

When a merge rendered its files in batches, State::build_graphs_detached
built each from its deferred history; a file the state had already
built has none, so it came out as an empty graph and an empty file, and
the merge recorded the whole file as inserted again, conflicting with
itself. VS Code's history failed verify from merge c67ef57cda90 on.
Built graphs are now copied. Regression tests for unrelated histories
adding the same file, one through the batched path. The 10 412-commit
reproducer imports and verifies every commit (41 million files).
OXY_MERGE_BATCH and OXY_MERGE_SHED override the thresholds for
measuring. closes #71 refs #25
Author
Andy McCall @andymccall <[email protected]>
When
· 2026-10-04 23:51 UTC
Signature
✔ signed by Andy McCall
Hash
33be24095b4bf535d1692e1f0fc7beac756c7402db5c3a12c3bad303e1a600a5
Depends on
57d8dc05c9d4 614f55e14eee 79ab8528ff62 8fd78eb52ad2 982864068a59 c07406bcc237 c3d2ef6104c2 d45dab0484ea f6451fbbae3f fd280228bdc2 ff0af1ee299d
Depended on by
454236d1108d dd21fd82687d
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.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
7 atoms, 0 tree operations, 4 files

Browse the tree at this change

modified CHANGELOG.md +2 −0
…
1 unchanged lines
2 2
3 3
## Unreleased
4 4
5 +
- **A large merge's file no longer imports empty (#71).** When a merge rendered its files in batches, a file whose graph the import had already built came out empty, so the merge recorded the whole file as inserted again and conflicted with itself: VS Code's history failed verification from 2016 on. Built graphs are now copied. The 10 412-commit reproducer imports and verifies every commit.
6 +
5 7
- **Git import holds a budget of graph memory (#25).** A long-lived file's graph keeps every line it ever had, and the import shed a kept state's graphs only past 2 000 of them; it now also sheds them past 1.5 million atoms, and gives freed memory back after each merge. A 28 637-commit slice of VS Code imports at 5.1 GB instead of 16.9 GB, in 36 minutes instead of 61, with the same verify result. The dependency check no longer memoises every queried change's whole ancestry.
6 8
7 9
- **A rotation follows you everywhere (#69).** `delegate add`, `delegate remove`, `rotate` and `renew` record the successor key in the user config when it named the old key, and in the repository's own file when that pinned it, so every checkout moves together. `oxy identity show` warns when a repository pins a key other than the user config's; `oxy config --unset KEY` drops a key; `oxy identity delegate add` takes several identity files in one rotation and reminds you that agents configured with your `author.key` need the new one.
…
104 unchanged lines
modified crates/oxy-core/src/repo/git.rs +10 −1
…
315 unchanged lines
316 316
    MERGE_BATCH_OVERRIDE
317 317
        .with(|c| c.get())
318 318
        .filter(|n| *n > 0)
319 +
        .or_else(|| {
320 +
            std::env::var("OXY_MERGE_BATCH")
321 +
                .ok()
322 +
                .and_then(|v| v.parse().ok())
323 +
        })
319 324
        .unwrap_or(MERGE_BATCH)
320 325
}
321 326
…
633 unchanged lines
955 960
                    let mut count_args = vec!["rev-list", "--count"];
956 961
                    count_args.extend(refs.iter().copied());
957 962
                    let others: usize = git(git_dir, &count_args)?.trim().parse().unwrap_or(0);
958 -
                    if others > MERGE_SHED_COMMITS {
963 +
                    let shed_at = std::env::var("OXY_MERGE_SHED")
964 +
                        .ok()
965 +
                        .and_then(|v| v.parse().ok())
966 +
                        .unwrap_or(MERGE_SHED_COMMITS);
967 +
                    if others > shed_at {
959 968
                        shed += base.shed_graphs(|b| self.store_blob(b))?;
960 969
                    }
961 970
                }
…
891 unchanged lines
modified crates/oxy-core/src/repo/tests.rs +228 −0
…
7235 unchanged lines
7236 7236
        .delegate_add(d1, crate::identity::Scope::CHANGES, None)
7237 7237
        .is_err());
7238 7238
}
7239 +
7240 +
/// Two unrelated histories that both add the same file, joined by two
7241 +
/// independent merges on two branches, then those branches merged: Git
7242 +
/// merges identical additions cleanly, and the import must too (#71,
7243 +
/// found in VS Code's history).
7244 +
#[test]
7245 +
fn git_import_joins_unrelated_histories_that_add_the_same_file() {
7246 +
    // Batches of one: every merge renders apart from the state, where a
7247 +
    // file whose graph the state had built came out empty (#71).
7248 +
    crate::repo::git::set_merge_batch_for_tests(Some(1));
7249 +
    hermetic();
7250 +
    if std::process::Command::new("git")
7251 +
        .arg("--version")
7252 +
        .output()
7253 +
        .is_err()
7254 +
    {
7255 +
        eprintln!("git not installed; skipping");
7256 +
        return;
7257 +
    }
7258 +
    let dir = tempfile::tempdir().unwrap();
7259 +
    let root = dir.path();
7260 +
    let g = root.join("src.git");
7261 +
    fs::create_dir_all(&g).unwrap();
7262 +
    let git = |args: &[&str]| -> String {
7263 +
        let out = std::process::Command::new("git")
7264 +
            .arg("-C")
7265 +
            .arg(&g)
7266 +
            .args([
7267 +
                "-c",
7268 +
                "user.name=Grace",
7269 +
                "-c",
7270 +
                "[email protected]",
7271 +
                "-c",
7272 +
                "commit.gpgsign=false",
7273 +
            ])
7274 +
            .args(args)
7275 +
            .output()
7276 +
            .unwrap();
7277 +
        assert!(
7278 +
            out.status.success(),
7279 +
            "git {args:?}: {}",
7280 +
            String::from_utf8_lossy(&out.stderr)
7281 +
        );
7282 +
        String::from_utf8_lossy(&out.stdout).trim().to_string()
7283 +
    };
7284 +
    let text = b"/* header */\nconst a = 1;\nconst b = 2;\n";
7285 +
    // History A: main.
7286 +
    git(&["init", "-q", "-b", "main"]);
7287 +
    write(&g, "gulpfile.js", text);
7288 +
    write(&g, "a.txt", b"a\n");
7289 +
    git(&["add", "."]);
7290 +
    git(&["commit", "-q", "-m", "A root"]);
7291 +
    // History B: an unrelated root adding the same file, identical.
7292 +
    git(&["checkout", "-q", "--orphan", "fork"]);
7293 +
    git(&["rm", "-q", "-r", "--cached", "."]);
7294 +
    for f in ["gulpfile.js", "a.txt"] {
7295 +
        let _ = fs::remove_file(g.join(f));
7296 +
    }
7297 +
    write(&g, "gulpfile.js", text);
7298 +
    write(&g, "b.txt", b"b\n");
7299 +
    git(&["add", "."]);
7300 +
    git(&["commit", "-q", "-m", "B root"]);
7301 +
    // Two branches from main, each joining B on its own.
7302 +
    git(&["checkout", "-q", "-b", "one", "main"]);
7303 +
    git(&[
7304 +
        "merge",
7305 +
        "-q",
7306 +
        "--no-ff",
7307 +
        "--allow-unrelated-histories",
7308 +
        "-m",
7309 +
        "join on one",
7310 +
        "fork",
7311 +
    ]);
7312 +
    git(&["checkout", "-q", "-b", "two", "main"]);
7313 +
    write(&g, "a.txt", b"a\ntwo\n");
7314 +
    git(&["commit", "-q", "-am", "work on two"]);
7315 +
    git(&[
7316 +
        "merge",
7317 +
        "-q",
7318 +
        "--no-ff",
7319 +
        "--allow-unrelated-histories",
7320 +
        "-m",
7321 +
        "join on two",
7322 +
        "fork",
7323 +
    ]);
7324 +
    // Then the two joins meet, and the file is reformatted after.
7325 +
    git(&["checkout", "-q", "main"]);
7326 +
    git(&["merge", "-q", "--no-ff", "-m", "merge one", "one"]);
7327 +
    git(&["merge", "-q", "--no-ff", "-m", "merge two", "two"]);
7328 +
    write(
7329 +
        &g,
7330 +
        "gulpfile.js",
7331 +
        b"/* header */\nconst a = 1;\nconst b = 3;\n",
7332 +
    );
7333 +
    git(&["commit", "-q", "-am", "after"]);
7334 +
7335 +
    let dest = root.join("dest");
7336 +
    let repo = Repo::init(&dest).unwrap().with_home(root.join("home"));
7337 +
    repo.import_git(&g, "main").unwrap();
7338 +
    let v = repo.verify_git_import(&g, "main").unwrap();
7339 +
    assert!(v.mismatches.is_empty(), "{:?}", v.mismatches);
7340 +
    assert_eq!(
7341 +
        fs::read(dest.join("gulpfile.js")).unwrap(),
7342 +
        b"/* header */\nconst a = 1;\nconst b = 3;\n"
7343 +
    );
7344 +
}
7345 +
7346 +
/// As above, but the two joins keep different copies (each branch had a
7347 +
/// different version when it joined), and later work edits each copy:
7348 +
/// the shape of VS Code's c67ef57cda90 (#71).
7349 +
#[test]
7350 +
fn git_import_joins_where_each_join_kept_a_different_copy() {
7351 +
    // Batches of one: every merge renders apart from the state, where a
7352 +
    // file whose graph the state had built came out empty (#71).
7353 +
    crate::repo::git::set_merge_batch_for_tests(Some(1));
7354 +
    hermetic();
7355 +
    if std::process::Command::new("git")
7356 +
        .arg("--version")
7357 +
        .output()
7358 +
        .is_err()
7359 +
    {
7360 +
        eprintln!("git not installed; skipping");
7361 +
        return;
7362 +
    }
7363 +
    let dir = tempfile::tempdir().unwrap();
7364 +
    let root = dir.path();
7365 +
    let g = root.join("src.git");
7366 +
    fs::create_dir_all(&g).unwrap();
7367 +
    let run = |args: &[&str]| {
7368 +
        std::process::Command::new("git")
7369 +
            .arg("-C")
7370 +
            .arg(&g)
7371 +
            .args([
7372 +
                "-c",
7373 +
                "user.name=Grace",
7374 +
                "-c",
7375 +
                "[email protected]",
7376 +
                "-c",
7377 +
                "commit.gpgsign=false",
7378 +
            ])
7379 +
            .args(args)
7380 +
            .output()
7381 +
            .unwrap()
7382 +
    };
7383 +
    let git = |args: &[&str]| -> String {
7384 +
        let out = run(args);
7385 +
        assert!(
7386 +
            out.status.success(),
7387 +
            "git {args:?}: {}",
7388 +
            String::from_utf8_lossy(&out.stderr)
7389 +
        );
7390 +
        String::from_utf8_lossy(&out.stdout).trim().to_string()
7391 +
    };
7392 +
    git(&["init", "-q", "-b", "main"]);
7393 +
    write(
7394 +
        &g,
7395 +
        "gulpfile.js",
7396 +
        b"/* header */\nconst a = 1;\nconst b = 2;\n",
7397 +
    );
7398 +
    git(&["add", "."]);
7399 +
    git(&["commit", "-q", "-m", "A root"]);
7400 +
    write(
7401 +
        &g,
7402 +
        "gulpfile.js",
7403 +
        b"/* header */\nconst a = 1;\n\nconst b = 2;\n",
7404 +
    );
7405 +
    git(&["commit", "-q", "-am", "whitespace"]);
7406 +
    git(&["checkout", "-q", "--orphan", "fork"]);
7407 +
    git(&["rm", "-q", "-r", "--cached", "."]);
7408 +
    let _ = fs::remove_file(g.join("gulpfile.js"));
7409 +
    write(
7410 +
        &g,
7411 +
        "gulpfile.js",
7412 +
        b"/* header */\nconst a = 1;\nconst b = 2;\nconst c = 3;\n",
7413 +
    );
7414 +
    git(&["add", "."]);
7415 +
    git(&["commit", "-q", "-m", "B root"]);
7416 +
    // Branch one joins B keeping main's copy; branch two keeps B's.
7417 +
    for (branch, keep) in [("one", "ours"), ("two", "theirs")] {
7418 +
        git(&["checkout", "-q", "-b", branch, "main"]);
7419 +
        let _ = run(&[
7420 +
            "merge",
7421 +
            "-q",
7422 +
            "--no-ff",
7423 +
            "--allow-unrelated-histories",
7424 +
            "-m",
7425 +
            "join",
7426 +
            "fork",
7427 +
        ]);
7428 +
        git(&["checkout", &format!("--{keep}"), "gulpfile.js"]);
7429 +
        git(&["add", "gulpfile.js"]);
7430 +
        git(&["commit", "-q", "-m", &format!("join on {branch}")]);
7431 +
    }
7432 +
    // Each branch then edits its copy.
7433 +
    git(&["checkout", "-q", "one"]);
7434 +
    write(
7435 +
        &g,
7436 +
        "gulpfile.js",
7437 +
        b"/* header */\nconst a = 1;\n\nconst b = 2;\n// tslint\n",
7438 +
    );
7439 +
    git(&["commit", "-q", "-am", "tslint on one"]);
7440 +
    git(&["checkout", "-q", "two"]);
7441 +
    write(
7442 +
        &g,
7443 +
        "gulpfile.js",
7444 +
        b"/* header */\nconst a = 1;\nconst b = 2;\nconst c = 3;\n// fmt\n",
7445 +
    );
7446 +
    write(&g, "extra.txt", b"x\n");
7447 +
    write(&g, "more.txt", b"m\n");
7448 +
    git(&["add", "."]);
7449 +
    git(&["commit", "-q", "-am", "fmt on two"]);
7450 +
    // They meet: resolved by hand.
7451 +
    git(&["checkout", "-q", "one"]);
7452 +
    let _ = run(&["merge", "-q", "--no-ff", "-m", "merge two", "two"]);
7453 +
    write(
7454 +
        &g,
7455 +
        "gulpfile.js",
7456 +
        b"/* header */\nconst a = 1;\nconst b = 2;\nconst c = 3;\n// tslint\n// fmt\n",
7457 +
    );
7458 +
    git(&["add", "gulpfile.js"]);
7459 +
    git(&["commit", "-q", "-m", "merge two into one"]);
7460 +
7461 +
    let dest = root.join("dest");
7462 +
    let repo = Repo::init(&dest).unwrap().with_home(root.join("home"));
7463 +
    repo.import_git(&g, "one").unwrap();
7464 +
    let v = repo.verify_git_import(&g, "one").unwrap();
7465 +
    assert!(v.mismatches.is_empty(), "{:?}", v.mismatches);
7466 +
}
modified crates/oxy-core/src/state.rs +15 −10
…
888 unchanged lines
889 889
        let Some(deferred) = self.deferred.as_ref() else {
890 890
            return Ok(Vec::new());
891 891
        };
892 -
        let copies: Vec<(NodeId, Deferred)> = nodes
893 -
            .iter()
894 -
            .map(|n| {
895 -
                (
896 -
                    *n,
897 -
                    deferred.get(n).map(|d| (**d).clone()).unwrap_or_default(),
898 -
                )
899 -
            })
900 -
            .collect();
901 -
        build_deferred(copies, load, read_change)
892 +
        // A file this state has built has no deferred history: its graph
893 +
        // is the built one, copied. Built from an empty history instead, it
894 +
        // rendered as an empty file, and a merge taking this path recorded
895 +
        // the whole file as inserted again, conflicting with itself (#71).
896 +
        let mut built: Vec<(NodeId, FileGraph)> = Vec::new();
897 +
        let mut copies: Vec<(NodeId, Deferred)> = Vec::new();
898 +
        for n in nodes {
899 +
            match (self.files.get(n), deferred.get(n)) {
900 +
                (Some(g), _) => built.push((*n, (**g).clone())),
901 +
                (None, d) => copies.push((*n, d.map(|d| (**d).clone()).unwrap_or_default())),
902 +
            }
903 +
        }
904 +
        let mut out = build_deferred(copies, load, read_change)?;
905 +
        out.extend(built);
906 +
        Ok(out)
902 907
    }
903 908
904 909
    /// Render a graph built for this state (see
…
1820 unchanged lines