OXYGENHUB

oxy git colocate: a .git beside .oxy, kept up to date after every command; .git is never content and never written by checkout (closes #160) c397a8c36a9f

Author
Andy McCall @andymccall <[email protected]>
via
agent agent:claude@andymccall <[email protected]> · keyless, under your grant for changes,refs,seal · key 9bbbb6bb407e
When
· 2026-11-01 01:25 UTC
Signature
◈ sealed by agent:claude@andymccall
Hash
c397a8c36a9f9b5249938b0715447836232c970d76d3952cd95a95d1282eaca3
Depends on
091fef80755d 16aa9e03d85b 30ea9c1e70fa 5ad2b56544c5 62794ecc7f74 6f4226bfb87f 7421309f5372 75167672ebcd 76740263089c 79ab8528ff62 83e0677b07fe 8fd78eb52ad2 982864068a59 ba160f60219e c6aa5bd9d1c8 d443639bcf35 d6e0d5b8b339 e0d56dfca51d fce44ae9ad29 ff0af1ee299d
Depended on by
2f0b4b0651ea
In bookmarks
main
In tags
v0.10.0 v0.11.0
Edits
21 atoms, 1 tree operation, 10 files

Browse the tree at this change

added tests/cli_colocate.rs +110 −0
1 +
//! `oxy git colocate` (oxygenhub#355, part two): a `.git` beside `.oxy`,
2 +
//! kept up to date after every oxy command, so Git tools read an Oxygen
3 +
//! checkout; oxy never takes `.git` as content.
4 +
5 +
use std::path::Path;
6 +
use std::process::Command;
7 +
8 +
fn oxy(dir: &Path, home: &Path, args: &[&str]) -> (bool, String) {
9 +
    let out = Command::new(env!("CARGO_BIN_EXE_oxy"))
10 +
        .arg("--plain")
11 +
        .args(args)
12 +
        .current_dir(dir)
13 +
        .env("OXY_HOME", home)
14 +
        .env("NO_COLOR", "1")
15 +
        .output()
16 +
        .unwrap();
17 +
    (
18 +
        out.status.success(),
19 +
        format!(
20 +
            "{}{}",
21 +
            String::from_utf8_lossy(&out.stdout),
22 +
            String::from_utf8_lossy(&out.stderr)
23 +
        ),
24 +
    )
25 +
}
26 +
27 +
fn git(dir: &Path, args: &[&str]) -> String {
28 +
    let out = Command::new("git")
29 +
        .args(args)
30 +
        .current_dir(dir)
31 +
        .env("GIT_CONFIG_NOSYSTEM", "1")
32 +
        .env("HOME", dir)
33 +
        .output()
34 +
        .unwrap();
35 +
    assert!(
36 +
        out.status.success(),
37 +
        "git {args:?}: {}",
38 +
        String::from_utf8_lossy(&out.stderr)
39 +
    );
40 +
    String::from_utf8_lossy(&out.stdout).trim().to_string()
41 +
}
42 +
43 +
#[test]
44 +
fn a_colocated_git_follows_the_bookmark() {
45 +
    if Command::new("git").arg("--version").output().is_err() {
46 +
        eprintln!("git not installed; skipping");
47 +
        return;
48 +
    }
49 +
    let tmp = tempfile::tempdir().unwrap();
50 +
    let home = tmp.path().join("home");
51 +
    std::fs::create_dir_all(&home).unwrap();
52 +
    let r = tmp.path().join("r");
53 +
    assert!(oxy(tmp.path(), &home, &["init", r.to_str().unwrap()]).0);
54 +
    assert!(
55 +
        oxy(
56 +
            &r,
57 +
            &home,
58 +
            &[
59 +
                "identity",
60 +
                "create",
61 +
                "--name",
62 +
                "Ada",
63 +
                "--email",
64 +
                "[email protected]"
65 +
            ]
66 +
        )
67 +
        .0
68 +
    );
69 +
    std::fs::write(r.join("a.txt"), "one\n").unwrap();
70 +
    assert!(oxy(&r, &home, &["commit", "-m", "first"]).0);
71 +
72 +
    let (ok, out) = oxy(&r, &home, &["git", "colocate"]);
73 +
    assert!(ok, "{out}");
74 +
    assert!(r.join(".git/HEAD").exists());
75 +
    assert_eq!(git(&r, &["log", "--format=%s"]), "first");
76 +
    assert_eq!(git(&r, &["symbolic-ref", "HEAD"]), "refs/heads/main");
77 +
    assert_eq!(
78 +
        git(&r, &["status", "--porcelain"]),
79 +
        "",
80 +
        "Git sees no difference, and not .oxy either"
81 +
    );
82 +
    let (_, status) = oxy(&r, &home, &["status"]);
83 +
    assert!(
84 +
        !status.contains(".git"),
85 +
        "oxy never takes .git as content: {status}"
86 +
    );
87 +
88 +
    // An oxy commit shows in Git without asking.
89 +
    std::fs::write(r.join("a.txt"), "one\ntwo\n").unwrap();
90 +
    assert_eq!(
91 +
        git(&r, &["status", "--porcelain"]),
92 +
        "M a.txt",
93 +
        "uncommitted work shows as modified"
94 +
    );
95 +
    assert!(oxy(&r, &home, &["commit", "-m", "second"]).0);
96 +
    assert_eq!(git(&r, &["log", "--format=%s"]), "second\nfirst");
97 +
    assert_eq!(git(&r, &["status", "--porcelain"]), "");
98 +
    assert_eq!(git(&r, &["show", "HEAD:a.txt"]), "one\ntwo");
99 +
100 +
    // Off: no more updates, the .git stays.
101 +
    assert!(oxy(&r, &home, &["git", "colocate", "--off"]).0);
102 +
    std::fs::write(r.join("b.txt"), "b\n").unwrap();
103 +
    assert!(oxy(&r, &home, &["commit", "-m", "third"]).0);
104 +
    assert_eq!(
105 +
        git(&r, &["log", "--format=%s"]),
106 +
        "second\nfirst",
107 +
        "no longer followed"
108 +
    );
109 +
    assert!(r.join(".git/HEAD").exists());
110 +
}
modified CHANGELOG.md +2 −0
…
1 unchanged lines
2 2
3 3
## Unreleased
4 4
5 +
- **Added: `oxy git colocate`, a `.git` beside `.oxy` (oxygenhub#355).** Your editor's Git features (blame, history, gutter marks) and other Git tools now work on an Oxygen checkout: oxy keeps a `.git` there with the current bookmark exported after every command, Git's index at its tree so `git status` shows only your uncommitted work, and `.oxy/` excluded. One way: commit with oxy. `--off` stops it.
6 +
- **Changed: `.git` is never content.** oxy no longer picks up a `.git` directory at a checkout's top, and checkout refuses a path with a `.git` component at any depth (as Git does), so a change cannot plant Git hooks.
5 7
- **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.)
6 8
- **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.
7 9
- **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.
…
308 unchanged lines
modified crates/oxy-core/src/repo/checkout.rs +37 −1
…
525 unchanged lines
526 526
            if i == 0 && *comp == OXY_DIR {
527 527
                return fail(format!("refusing to write inside {OXY_DIR}: {rel:?}"));
528 528
            }
529 +
            // A `.git` path could plant hooks a side-by-side Git would run;
530 +
            // Git refuses them at any depth and in any case, and so does oxy.
531 +
            if comp.eq_ignore_ascii_case(".git") {
532 +
                return fail(format!("refusing to write a .git path: {rel:?}"));
533 +
            }
529 534
            if i + 1 < comps.len() {
530 535
                full.push(comp);
531 536
                if let Ok(m) = fs::symlink_metadata(&full) {
…
64 unchanged lines
596 601
}
597 602
598 603
/// Refuse a path that could escape the workspace (spec §7.7): an empty,
599 -
/// `.` or `..` component, a backslash, or a first component `.oxy`.
604 +
/// `.` or `..` component, a backslash, a first component `.oxy`, or a
605 +
/// `.git` component anywhere (it could plant Git hooks).
600 606
fn check_path_name(rel: &str) -> Result<()> {
601 607
    for (i, comp) in rel.split('/').enumerate() {
602 608
        if comp.is_empty() || comp == "." || comp == ".." || comp.contains('\\') {
…
1 unchanged lines
604 610
        }
605 611
        if i == 0 && comp == OXY_DIR {
606 612
            return fail(format!("refusing to write inside {OXY_DIR}: {rel:?}"));
613 +
        }
614 +
        if comp.eq_ignore_ascii_case(".git") {
615 +
            return fail(format!("refusing to write a .git path: {rel:?}"));
607 616
        }
608 617
    }
609 618
    Ok(())
…
37 unchanged lines
647 656
    }
648 657
    fs::write(full, bytes)?;
649 658
    set_executable(full, exec)
659 +
}
660 +
661 +
#[cfg(test)]
662 +
mod path_name_tests {
663 +
    use super::check_path_name;
664 +
665 +
    #[test]
666 +
    fn git_paths_are_refused_at_any_depth_and_case() {
667 +
        for bad in [
668 +
            ".git/hooks/post-checkout",
669 +
            "sub/.git/config",
670 +
            ".GIT/hooks/x",
671 +
            "a/.Git",
672 +
            ".oxy/x",
673 +
            "../x",
674 +
        ] {
675 +
            assert!(check_path_name(bad).is_err(), "{bad}");
676 +
        }
677 +
        for good in [
678 +
            "src/main.rs",
679 +
            ".gitignore",
680 +
            ".github/workflows/ci.yml",
681 +
            "a/.gitmodules",
682 +
        ] {
683 +
            assert!(check_path_name(good).is_ok(), "{good}");
684 +
        }
685 +
    }
650 686
}
modified crates/oxy-core/src/repo/config.rs +6 −0
…
45 unchanged lines
46 46
    /// `git.claim`: a Git import authors the commits whose Git author email
47 47
    /// is this repository's identity's with its key (oxygen#97).
48 48
    pub git_claim: bool,
49 +
    /// `git.colocate`: keep a `.git` beside `.oxy` with the current
50 +
    /// bookmark exported after every operation, so editors' Git features
51 +
    /// work on an Oxygen checkout (one way: oxy to Git).
52 +
    pub git_colocate: bool,
49 53
    /// `core.op-retention`: days for which operations keep their states reachable (spec §12.1, §21).
50 54
    pub op_retention_days: u64,
51 55
    /// `core.gc-grace`: days an unreachable object survives, protecting concurrent writers (spec §21).
…
56 unchanged lines
108 112
            remote_cas: BTreeMap::new(),
109 113
            default_remote: None,
110 114
            git_claim: false,
115 +
            git_colocate: false,
111 116
            op_retention_days: 90,
112 117
            gc_grace_days: 14,
113 118
            max_new_file: 100 << 20,
…
101 unchanged lines
215 220
            }
216 221
            "hooks.semantic-deps" => self.semantic_deps = matches!(value, "true" | "1" | "yes"),
217 222
            "git.claim" => self.git_claim = matches!(value, "true" | "1" | "yes"),
223 +
            "git.colocate" => self.git_colocate = matches!(value, "true" | "1" | "yes"),
218 224
            "filter.paths" => {
219 225
                self.sparse_paths = value
220 226
                    .split(',')
…
163 unchanged lines
modified crates/oxy-core/src/repo/git.rs +60 −0
…
2399 unchanged lines
2400 2400
    }
2401 2401
    Ok(())
2402 2402
}
2403 +
2404 +
impl Repo {
2405 +
    /// Bring the side-by-side Git repository up to date (`git.colocate`):
2406 +
    /// a `.git` beside `.oxy`, made with `git init` when missing and told to
2407 +
    /// leave `.oxy/` alone, holding the current bookmark as exported by
2408 +
    /// `git_export_as` (one commit per state the bookmark passed through),
2409 +
    /// with HEAD on that branch and Git's index at its tree, so `git status`
2410 +
    /// shows only the working copy's uncommitted work. Files on disk are
2411 +
    /// never touched. Does nothing when the bookmark has not moved since the
2412 +
    /// last time; `Ok(true)` when it exported.
2413 +
    pub fn git_colocate_sync(&self) -> Result<bool> {
2414 +
        if self.bare {
2415 +
            return fail("a bare repository has no working copy for a .git beside it");
2416 +
        }
2417 +
        let git_dir = self.root.join(".git");
2418 +
        if git_dir.is_file() {
2419 +
            return fail(".git is a file (a Git worktree or submodule link); remove it to keep a side-by-side Git repository here");
2420 +
        }
2421 +
        if !git_dir.join("HEAD").exists() {
2422 +
            git(&self.root, &["init", "-q"])?;
2423 +
        }
2424 +
        let exclude = git_dir.join("info").join("exclude");
2425 +
        let have = fs::read_to_string(&exclude).unwrap_or_default();
2426 +
        if !have.lines().any(|l| l.trim() == "/.oxy/") {
2427 +
            fs::create_dir_all(exclude.parent().expect("info/"))?;
2428 +
            let sep = if have.is_empty() || have.ends_with('\n') {
2429 +
                ""
2430 +
            } else {
2431 +
                "\n"
2432 +
            };
2433 +
            fs::write(
2434 +
                &exclude,
2435 +
                format!("{have}{sep}# Oxygen's own repository (oxy git colocate)\n/.oxy/\n"),
2436 +
            )?;
2437 +
        }
2438 +
        let bookmark = self.config.bookmark.clone();
2439 +
        let state = self
2440 +
            .view()?
2441 +
            .bookmarks
2442 +
            .get(&bookmark)
2443 +
            .map(|s| s.to_hex())
2444 +
            .unwrap_or_default();
2445 +
        let stamp_file = self.oxy.join("git").join("colocate-stamp");
2446 +
        let stamp = format!("{bookmark} {state}");
2447 +
        if fs::read_to_string(&stamp_file).is_ok_and(|s| s.trim() == stamp) {
2448 +
            return Ok(false);
2449 +
        }
2450 +
        if !state.is_empty() {
2451 +
            self.git_export_as(&git_dir, &bookmark)?;
2452 +
            git(
2453 +
                &self.root,
2454 +
                &["symbolic-ref", "HEAD", &format!("refs/heads/{bookmark}")],
2455 +
            )?;
2456 +
            git(&self.root, &["read-tree", "HEAD"])?;
2457 +
        }
2458 +
        fs::create_dir_all(stamp_file.parent().expect("git/"))?;
2459 +
        fs::write(&stamp_file, stamp)?;
2460 +
        Ok(!state.is_empty())
2461 +
    }
2462 +
}
modified crates/oxy-core/src/repo/walk.rs +3 −1
…
184 unchanged lines
185 185
    let mut mine = Listing::default();
186 186
    mine.dirs.push((rel.clone(), mtime));
187 187
    for (e, name) in entries.into_iter().zip(names) {
188 -
        if name == OXY_DIR {
188 +
        // `.git` is never content: a side-by-side Git repository
189 +
        // (`git.colocate`), or Git's own beside an import.
190 +
        if name == OXY_DIR || name.eq_ignore_ascii_case(".git") {
189 191
            continue;
190 192
        }
191 193
        let Ok(ft) = e.file_type() else { continue };
…
136 unchanged lines
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 +
- **A side-by-side `.git`.** `oxy git colocate` (config `git.colocate`) keeps a `.git` beside `.oxy` so editors' and other tools' Git features work on an Oxygen checkout: made with `git init` when missing, `/.oxy/` added to its `info/exclude`, the current bookmark exported into it (as `oxy git export` does) after every oxy command that leaves the bookmark somewhere new, HEAD on that branch and the index at its tree, so `git status` shows exactly the working copy's uncommitted work. It is one way: commits made with Git are not read back. oxy never takes a `.git` directory as content, and checkout refuses to write a path with a `.git` component at any depth or case (as Git does), so a change cannot plant hooks there. `oxy git colocate --off` stops the updates and leaves `.git` in place.
1014 1015
- **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.
1015 1016
- **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.
1016 1017
…
180 unchanged lines
modified src/cli/mod.rs +32 −1
…
534 unchanged lines
535 535
        #[command(subcommand)]
536 536
        cmd: PolicyCmd,
537 537
    },
538 -
    /// Set a configuration key (author.*, core.sign, core.op-retention, core.gc-grace, core.checkpoint-interval, filter.max-blob, filter.paths, storage.cache, workspace.bookmark, remote.<name>.url|token|ca, auth.helper with --user)
538 +
    /// Set a configuration key (author.*, core.sign, core.op-retention, core.gc-grace, core.checkpoint-interval, filter.max-blob, filter.paths, storage.cache, workspace.bookmark, remote.<name>.url|token|ca, git.claim, git.colocate, auth.helper with --user)
539 539
    Config {
540 540
        /// The key, such as author.name or remote.origin.token
541 541
        key: String,
…
522 unchanged lines
1064 1064
1065 1065
#[derive(Subcommand)]
1066 1066
enum GitCmd {
1067 +
    /// Keep a .git beside .oxy, up to date after every oxy command, so editors' Git features work here (one way: oxy to Git)
1068 +
    Colocate {
1069 +
        /// Stop updating it (the .git is left as it is)
1070 +
        #[arg(long)]
1071 +
        off: bool,
1072 +
    },
1067 1073
    /// Export the current bookmark to a Git repository (created bare if absent)
1068 1074
    Export {
1069 1075
        /// Git repository to write to (created bare if absent)
…
138 unchanged lines
1208 1214
    },
1209 1215
}
1210 1216
1217 +
/// After a command, bring the side-by-side `.git` up to date when this
1218 +
/// checkout keeps one (`git.colocate`). It never fails the command: a
1219 +
/// problem is a warning, and the next command tries again.
1220 +
pub fn after_command() {
1221 +
    if AT_OP.get().and_then(|o| o.as_deref()).is_some() {
1222 +
        return;
1223 +
    }
1224 +
    let Ok(dir) = std::env::current_dir() else {
1225 +
        return;
1226 +
    };
1227 +
    let Ok(repo) = Repo::open(&dir) else { return };
1228 +
    if !repo.config.git_colocate || repo.is_bare() {
1229 +
        return;
1230 +
    }
1231 +
    if let Err(e) = repo.git_colocate_sync() {
1232 +
        eprintln!(
1233 +
            "{} the .git beside this checkout was not updated: {e}",
1234 +
            ui::warn("!")
1235 +
        );
1236 +
    }
1237 +
}
1238 +
1211 1239
/// Open the repository containing the current directory.
1212 1240
fn open() -> Result<Repo> {
1213 1241
    let repo = Repo::open(&std::env::current_dir()?).context("opening repository")?;
…
345 unchanged lines
1559 1587
                DelegateCmd::List => identity::delegate_list(),
1560 1588
            },
1561 1589
        },
1590 +
        Cmd::Git {
1591 +
            cmd: GitCmd::Colocate { off },
1592 +
        } => sync::git_colocate(off),
1562 1593
        Cmd::Git {
1563 1594
            cmd: GitCmd::Export { git_dir, branch },
1564 1595
        } => sync::git_export(&git_dir, branch.as_deref()),
…
120 unchanged lines
modified src/cli/sync.rs +29 −0
…
750 unchanged lines
751 751
    Ok(0)
752 752
}
753 753
754 +
/// `oxy git colocate [--off]`: keep a `.git` beside `.oxy` (spec §23).
755 +
pub fn git_colocate(off: bool) -> Result<i32> {
756 +
    let mut repo = open()?;
757 +
    if repo.is_bare() {
758 +
        anyhow::bail!("a bare repository has no working copy for a .git beside it");
759 +
    }
760 +
    repo.config
761 +
        .set("git.colocate", if off { "false" } else { "true" })?;
762 +
    repo.save_config()?;
763 +
    if off {
764 +
        println!(
765 +
            "{} the .git beside this checkout is no longer updated {}",
766 +
            ui::done("Stopped:"),
767 +
            ui::dim("(it is left as it is: delete .git yourself if you want it gone)")
768 +
        );
769 +
        return Ok(0);
770 +
    }
771 +
    let sp = ui::spinner("Exporting to .git…");
772 +
    repo.git_colocate_sync()?;
773 +
    sp.clear();
774 +
    println!(
775 +
        "{} .git beside .oxy holds {} {}",
776 +
        ui::done("Colocated:"),
777 +
        ui::bookmark(&repo.config.bookmark),
778 +
        ui::dim("(updated after every oxy command; Git's own commits are not read back: commit with oxy)")
779 +
    );
780 +
    Ok(0)
781 +
}
782 +
754 783
pub fn git_export(git_dir: &std::path::Path, branch: Option<&str>) -> Result<i32> {
755 784
    let repo = open()?;
756 785
    let sp = ui::spinner("Exporting to Git…");
…
439 unchanged lines
modified src/main.rs +3 −0
…
34 unchanged lines
35 35
    }
36 36
    let command = oxy_core::timing::phase("command");
37 37
    let result = cli::run();
38 +
    if result.is_ok() {
39 +
        cli::after_command();
40 +
    }
38 41
    drop(command);
39 42
    oxy_core::timing::memory_summary();
40 43
    match result {
…
8 unchanged lines