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 |