From 1fc85f08c4281a6cc2a4e91cfd3a24eb0c854b80 Mon Sep 17 00:00:00 2001 From: Kerry Hatcher Date: Sun, 26 Jul 2026 16:38:33 -0400 Subject: [PATCH 1/4] docs(status): plan gix-based gor status implementation Add handoff document, status story, and research doc for implementing gor status using gix (gitoxide) instead of jj-lib. Key advantages of gix over jj-lib: - Already a dependency (no new transitives) - Fully synchronous (no tokio runtime) - No .jj/ directory side effects - Index-based status with lstat caching (fast) --- HANDOFF.md | 224 ++++++++++++++++++++++++++ _typos.toml | 2 + docs/issues/status.md | 205 +++++++++++++++++++++++ docs/research/research-gix-for-gor.md | 208 ++++++++++++++++++++++++ 4 files changed, 639 insertions(+) create mode 100644 HANDOFF.md create mode 100644 docs/issues/status.md create mode 100644 docs/research/research-gix-for-gor.md diff --git a/HANDOFF.md b/HANDOFF.md new file mode 100644 index 0000000..79f40e3 --- /dev/null +++ b/HANDOFF.md @@ -0,0 +1,224 @@ +# Handoff: `gor status` via gix (gitoxide) + +## Context + +This worktree is a branch (`feat/jj-lib-integration`) off `main` for adding +git CLI capabilities to `gor` using **`gix` (gitoxide)** — already a +dependency — as the VCS engine. + +The goal is to make `gor` a drop-in replacement for common `git` commands +while remaining a pure-Rust, no-OpenSSL, no-`git`-binary CLI. We start with +`gor status` as the first integration. + +## Design Decision + +We use **`gix` directly** rather than wrapping `jj-lib`. Rationale: + +- `gix` is already a dependency (no new transitives, no compile-time cost) +- Fully synchronous — no tokio runtime, no async-to-sync bridge +- No `.jj/` directory written to user repos +- Git's index-based status is fast (lstat caching, not full tree walk) +- `gix` 0.85 is mature and the gitoxide project is well-established + +See `docs/research/research-gix-for-gor.md` for full capability mapping. + +**`jj-lib` is deferred** — it would only be added later if a specific +command genuinely needs its rewrite/sequencer engine (e.g., `gor rebase`). + +## Reference Documents + +| Document | Location | Contents | +|---|---|---| +| gix-for-gor research | `docs/research/research-gix-for-gor.md` | Full capability mapping, API reference, gix-vs-jj-lib comparison | +| gor status spec | `docs/issues/status.md` | Full story for `gor status`: acceptance criteria, CLI design, output formats, implementation architecture, testing strategy | + +## High-Level Implementation Plan + +### Phase 0: VCS module scaffold (no new dependencies) + +No `Cargo.toml` changes needed — `gix` is already enabled with all +features required for status: + +```toml +# Already in Cargo.toml: +gix = { version = "0.85", default-features = false, features = [ + "basic", "sha1", + "blocking-http-transport-reqwest-rust-tls", + "worktree-mutation", "status", +] } +``` + +1. **Create `src/vcs/mod.rs`** — the new VCS module. No async runtime + needed; `gix` is synchronous. + ```rust + // src/vcs/mod.rs + //! Git VCS operations backed by gix (gitoxide). + mod repo; + mod types; + pub use repo::GitRepo; + pub use types::{WorkingTreeStatus, FileStatus, ChangeType}; + ``` + +2. **Create `src/vcs/types.rs`** — status types (unchanged from earlier + plan, these are format-agnostic): + ```rust + pub struct WorkingTreeStatus { + pub branch: Option, + pub upstream: Option, + pub ahead: u32, + pub behind: u32, + pub staged: Vec, + pub unstaged: Vec, + pub untracked: Vec, + pub conflicted: Vec, + } + + pub struct FileStatus { + pub path: String, + pub status: ChangeType, + } + + pub enum ChangeType { + Modified, Added, Deleted, TypeChange, Renamed { from: String }, + } + ``` + +3. **Create `src/vcs/repo.rs`** — the `GitRepo` struct wrapping `gix`: + ```rust + use gix::{Repository, Status}; + + pub struct GitRepo { + repo: Repository, + } + + impl GitRepo { + /// Open or discover a git repository from the given directory. + /// Walks up parent directories looking for `.git/`. + pub fn open(path: &Path) -> Result { + Ok(Self { repo: gix::discover(path).map_err(VcsError::Git)? }) + } + + /// Get the full working tree status in one pass. + pub fn status(&self) -> Result { + let status = self.repo + .status(gix::worktree::Status::default())?; + + let branch = /* repo.head() -> name */; + let upstream = /* branch.upstream() -> remote ref */; + let (ahead, behind) = /* graph.ahead_behind() */; + let conflicted = /* index entries with stage > 0 */; + + Ok(WorkingTreeStatus { + branch, + upstream, + ahead, + behind, + staged: status.staged()?.map(/* to FileStatus */).collect(), + unstaged: status.unstaged()?.map(/* to FileStatus */).collect(), + untracked: status.untracked()?.map(|e| e.path).collect(), + conflicted, + }) + } + } + ``` + + - `gix::discover()` walks up directories to find `.git/` — no `.jj/` needed + - `repo.status()` is a single, index-based pass — no `snapshot()` overhead + - `.gitignore` is evaluated automatically by the dirwalk + +4. **Register the module** in `src/lib.rs`: + ```rust + pub mod vcs; + ``` + + No feature gate — `gix` is always available, and adding git commands to + a GitHub CLI is a feature, not a dependency burden. + +### Phase 1: CLI + dispatch + +5. **Add `Status` variant** to `Command` enum in `src/cli.rs`: + ```rust + /// Show working tree status. + #[command(name = "status")] + Status(StatusCommand), + ``` + Copy the `StatusCommand` struct from `docs/issues/status.md`. + +6. **Create `src/cmd/status.rs`** — the command handler: + ```rust + pub fn run(cmd: StatusCommand) -> anyhow::Result<()> { + let repo = vcs::GitRepo::open(&std::env::current_dir()?)?; + let status = repo.status()?; + output::print_status(&status, &cmd); + Ok(()) + } + ``` + +7. **Wire dispatch** in `src/cmd/mod.rs` — add the match arm: + ```rust + Command::Status(cmd) => status::run(cmd), + ``` + +### Phase 2: Output formatting + +8. **Add status formatting** to `src/output.rs`: + - `print_status()` — default format (git-style grouped output) + - `print_status_short()` — `--short` format + - `print_status_json()` — `--json` format + + Use `insta` snapshot tests for each format variant. + +### Phase 3: Testing + +9. **Integration tests** in `tests/status_integration.rs`: + - Create temp git repos with `gix` at known states + - Run `gor status` via `assert_cmd` + - Snapshot the output + +## Key Design Decisions + +- **No feature gate.** Unlike jj-lib (which would need one to avoid + pulling in 30+ transitives), `gix` is already compiled. The VCS module + should always be available. +- **`gix::discover()` over `gix::open()`.** Walk up for `.git/` so `gor + status` works from any subdirectory, just like `git status`. +- **Single `gix::Status` pass.** gix collects staged, unstaged, untracked, + and ignored in one directory walk. Don't reconstruct it with separate + API calls. +- **Index-based, not snapshot-based.** `gix::Status` uses lstat to check + file modification times against the index, skipping unchanged files. + No full tree walk unless something changed. +- **Rename detection off by default.** `gix::diff::DetectRenames` is + opt-in via `--renames` for performance. + +## Risks and Gotchas + +- `gix::discover()` respects `$GIT_DIR` and `$GIT_DISCOVERY_ACROSS_FILESYSTEM`. + Test edge cases with environment variables. +- `gix::Status` returns paths relative to the working tree root, not the + current directory. Normalize for display (like `git status` does). +- Stat-caching means `gix` may miss files touched by external tools if + mtime isn't updated. Falls back to content comparison. +- `gix` 0.85 is still pre-1.0. Pin the exact version (already done). +- Conflicted files come from `gix::Index::entries()` with `stage() > 0`, + not from `gix::Status`. Need a separate index read. + +## First Steps for the Agent + +1. `cd /home/kwhatcher/projects/gor-jj-integration` +2. Read the two reference docs above +3. Start with Phase 0: create the `vcs` module (`mod.rs`, `types.rs`, `repo.rs`) +4. Run `cargo build` to validate compilation +5. Move to Phase 1: add the CLI arg + dispatch +6. Iterate + +Refer to the existing command implementations +(`src/cmd/repo.rs`, `src/cmd/pr.rs`) for patterns around CLI args, error +handling, and output formatting. + +## Contact / Commit Convention + +Use conventional commits with scope `vcs` for git VCS work: +- `feat(vcs): add gix-based GitRepo wrapper` +- `feat(status): implement gor status command` +- `test(status): add integration tests for working tree status` diff --git a/_typos.toml b/_typos.toml index 8686454..2857b08 100644 --- a/_typos.toml +++ b/_typos.toml @@ -11,6 +11,8 @@ recieve = "receive" # Valid crate names — not typos. ratatui = "ratatui" pitty = "pitty" +worktree = "worktree" +workt = "workt" [files] # Don't spellcheck lockfiles or build output. diff --git a/docs/issues/status.md b/docs/issues/status.md new file mode 100644 index 0000000..81482ed --- /dev/null +++ b/docs/issues/status.md @@ -0,0 +1,205 @@ +--- +tags: [vcs, git, status] +priority: P1 +phase: 0 +endpoints: [] +status: todo +blockedBy: [] +blocks: [diff, commit, add] +--- + +# Status — Working Tree Status + +## As a + +developer who wants to see what changed in my working directory + +## I want + +to run `gor status` and see a summary of changed, staged, untracked, and +conflicted files — just like `git status` + +## Acceptance criteria + +### Phase 1 — Basic status + +1. Running `gor status` in a git repo displays: + - **Changed (unstaged)** files with their change type (modified, deleted, + added, type change) + - **Staged** files (changes staged for commit) + - **Untracked** files (new files not yet tracked) + - **Conflicted** files (merge conflicts) + - Files are grouped and labelled clearly (like `git status`) +2. Running outside a git repo prints a clear error +3. `--short` / `-s` flag prints the short/porcelain format (`M`, ` A`, + `??`, etc.) one file per line, matching `git status --short` +4. `--branch` / `-b` flag (can combine with `--short`) prints the branch + name and tracking relationship, matching `git status --short --branch` +5. `--ignored` flag shows ignored files (hidden by default) +6. `--renames` flag detects renames (default: off for performance) +7. `--json` flag outputs structured JSON with per-file status, matching + gor's cross-cutting `--json` convention +8. Exit code 0 on success, non-zero on error + +### Phase 2 — Rich output + +9. `gor status` shows the number of insertions/deletions per file +10. `gor status` shows the current branch, its upstream tracking branch, + and ahead/behind counts +11. `gor status` highlights conflicted files with visible markers and + suggests commands to resolve + +### Phase 3 — Integration + +12. `--repo` / `-R OWNER/REPO` flag works inside git repos that have a + GitHub remote (to associate the status with a repo context) +13. `gor status` respects `.gitignore` rules +14. `gor status` respects `core.excludesFile` and local exclude rules + +## Out of scope + +- Showing status for submodules (deferred) +- Interactive `gor add -i` or patch mode +- `gor status` as a GitHub issue/PR dashboard (that's a separate command, + similar to `gh status`) + +## Implementation notes + +### Architecture + +Add a new module `src/cmd/status.rs` integrating with a new `gor::vcs` +module that wraps `gix` (gitoxide) — already a dependency — as the VCS engine. + +The status pipeline: + +``` +gor status (CLI args) + └─ cmd::status::run() + └─ vcs::GitRepo::open(".") // gix::discover() — walks up for .git/ + └─ vcs::GitRepo::status() // gix::Repository::status() + ├─ Changed files // status.staged() + status.unstaged() + ├─ Staged files // status.staged() (index vs HEAD) + ├─ Untracked files // status.untracked() (with .gitignore) + └─ Conflicted files // repo.index().entries() where stage > 0 + └─ output::print_status() // gor's output module +``` + +### Key gix integration points + +- `gix::discover()` — walk up from `path` to find `.git/` (handles subdirectories) +- `gix::Repository::status()` — single-pass status collecting staged, + unstaged, untracked, and ignored files. Uses index-based stat caching + for performance (no full tree walk). +- `gix::Repository::head()` — current HEAD reference, branch name +- `gix::Repository::find_reference()` — upstream tracking branch +- `gix::Repository::graph().ahead_behind()` — ahead/behind counts +- `gix::Repository::index().entries().filter(stage > 0)` — conflicted files +- `gix::diff::DetectRenames` — optional rename detection (off by default) + +### No new dependencies needed + +`gix` is already in `Cargo.toml` with all features required: + +```toml +gix = { version = "0.85", default-features = false, features = [ + "basic", + "sha1", + "blocking-http-transport-reqwest-rust-tls", + "worktree-mutation", + "status", +] } +``` + +No feature gate is needed — unlike `jj-lib` (which would pull in 30+ +transitive crates), `gix` is already compiled. + +### CLI definition (in `cli.rs`) + +```rust +/// Show working tree status. +#[derive(clap::Args, Debug)] +pub struct StatusCommand { + /// Show short/porcelain format. + #[arg(short = 's', long)] + pub short: bool, + + /// Show branch and tracking info (with --short). + #[arg(short = 'b', long)] + pub branch: bool, + + /// Show ignored files. + #[arg(long)] + pub ignored: bool, + + /// Detect renames (may be slow on large repos). + #[arg(long)] + pub renames: bool, + + /// Output as JSON. Optionally specify comma-separated field names. + #[arg(long, num_args = 0.., value_delimiter = ',')] + pub json: Option>, + + /// Repository (OWNER/REPO format). Auto-detected from git remote. + #[arg(short = 'R', long)] + pub repo: Option, +} +``` + +### Output format examples + +Default output: +``` +On branch feat/awesome-feature +Your branch is up to date with 'origin/feat/awesome-feature'. + +Changes not staged for commit: + (use "gor add ..." to update what will be committed) + modified: src/cmd/status.rs + deleted: src/old-module.rs + +Changes staged for commit: + (use "gor restore --staged ..." to unstage) + modified: Cargo.toml + +Untracked files: + (use "gor add ..." to include in what will be committed) + docs/research/research-jj-lib.md +``` + +Short format: +``` +## feat/awesome-feature...origin/feat/awesome-feature + M src/cmd/status.rs + D src/old-module.rs +M Cargo.toml +?? docs/research/research-jj-lib.md +``` + +JSON format: +```json +{ + "branch": "feat/awesome-feature", + "upstream": "origin/feat/awesome-feature", + "ahead": 3, + "behind": 0, + "conflicted": [], + "staged": [ + { "path": "Cargo.toml", "status": "modified" } + ], + "unstaged": [ + { "path": "src/cmd/status.rs", "status": "modified" }, + { "path": "src/old-module.rs", "status": "deleted" } + ], + "untracked": [ + "docs/research/research-jj-lib.md" + ] +} +``` + +### Testing strategy + +- **Unit tests:** Pure status formatting in `output.rs` with `insta` snapshots +- **Integration tests:** Create temp git repos with known states (dirty, + staged, clean, conflicted) and verify `gor status` output using `assert_cmd` +- **Edge cases:** Empty repos, repos with submodules, repos with + `.gitignore`, repos with merge conflicts, repos with no upstream diff --git a/docs/research/research-gix-for-gor.md b/docs/research/research-gix-for-gor.md new file mode 100644 index 0000000..e8c832b --- /dev/null +++ b/docs/research/research-gix-for-gor.md @@ -0,0 +1,208 @@ +# Research: Using `gix` (gitoxide) as the Git Engine for `gor` + +## Summary + +`gix` (gitoxide, v0.85) is a pure-Rust implementation of the Git version +control system, split across ~40 subcrates. `gor` already depends on `gix` +for HTTP transport, remote detection, and clone operations. + +This document evaluates how much of a git porcelain `gor` can build using +only `gix` — without adding `jj-lib` or any other VCS dependency. + +--- + +## gix Architecture + +``` +┌────────────────────────────────────────────┐ +│ gix (high-level) │ +│ Repository, Reference, Worktree, Status │ +├──────────┬──────────┬──────────┬───────────┤ +│ gix-odb │ gix-ref │ gix-index│ gix-diff │ +│ (object │ (refs, │ (staging │ (diff │ +│ store) │ HEAD) │ area) │ engine) │ +├──────────┼──────────┼──────────┼───────────┤ +│ gix-pack │ gix-prot │ gix-work │ gix-traver│ +│ (pack │ ocol │ tree-stat│ se │ +│ files) │ (fetch/ │ e (workt │ (history │ +│ │ push) │ ree I/O)│ walk) │ +├──────────┴──────────┴──────────┴───────────┤ +│ gix-object (blobs, trees, │ +│ commits, tags) │ +└────────────────────────────────────────────┘ +``` + +### Key subcrates and their roles + +| Subcrate | Role | Already in lockfile? | +|---|---|---| +| `gix` | High-level facade — `Repository`, `Status`, `Worktree` | ✅ Yes | +| `gix-odb` | Object store — read/write loose + pack files | ✅ Yes | +| `gix-object` | Object types — `Blob`, `Tree`, `Commit`, `Tag` | ✅ Yes | +| `gix-ref` | Reference operations — HEAD, branches, tags | ✅ Yes | +| `gix-index` | Staging area (index) read/write | ✅ Yes | +| `gix-diff` | Tree diff engine | ✅ Yes | +| `gix-status` | Working tree status — staged, unstaged, untracked | ✅ Yes | +| `gix-dir` | Directory walk — `.gitignore` evaluation | ✅ Yes | +| `gix-worktree` | Working tree entry management | ✅ Yes | +| `gix-worktree-state` | Checkout/restore files to disk | ✅ Yes | +| `gix-traverse` | Tree/commit traversal | ✅ Yes | +| `gix-revision` | Revision parsing, merge-base, describe | ✅ Yes | +| `gix-revwalk` | Commit graph walking | ✅ Yes | +| `gix-commitgraph` | Commit-graph file (speed up history) | ✅ Yes | +| `gix-config` | `.git/config` read/write | ✅ Yes | +| `gix-protocol` | Fetch/push protocol | ✅ Yes | +| `gix-transport` | Transport layer (HTTP, SSH, file://) | ✅ Yes | +| `gix-credentials` | Credential helpers | ✅ Yes | +| `gix-refspec` | Refspec parsing and matching | ✅ Yes | +| `gix-negotiate` | Fetch negotiation (haves/wants) | ✅ Yes | +| `gix-blame` | Line-by-line blame — **available via `blame` feature** | ❌ Not enabled | +| `gix-merge` | 3-way tree merge — **available via `merge` feature** | ❌ Not enabled | +| `gix-submodule` | Submodule operations | ✅ In lockfile | +| `gix-ignore` | `.gitignore` / `.gitattributes` ignore rules | ✅ Yes | +| `gix-filter` | Git filter drivers (smudge/clean) | ✅ Yes | + +--- + +## Command-by-Command Capability Mapping + +### ✅ Ready with currently enabled features + +Current `gor` features: `basic`, `sha1`, `blocking-http-transport-reqwest-rust-tls`, `worktree-mutation`, `status` + +| Command | gix API | Notes | +|---|---|---| +| **status** | `repo.status(…)` → `gix::Status` | Single API call returns staged, unstaged, untracked, ignored. `.gitignore` evaluated automatically. | +| **diff** | `repo.diff_tree_to_tree(…)`, `gix::diff::DetectRenames` | Diff any two trees (HEAD↔index, index↔worktree, HEAD↔HEAD~1). Can detect renames. | +| **log** | `gix::revision::walk()` via `gix-revwalk` | Walk commits in topological/date order. Filter by path, author, etc. Format with `gix-actor`. | +| **add** | `repo.index()` → modify entries → `index.write()` | Stage files by updating index entries. Handle `.gitignore` for warnings. | +| **commit** | `repo.index().write_tree()` → `gix::Object::write()` → `repo.refs().set_referent()` | Write tree from index, create commit object, update HEAD. | +| **branch** | `repo.refs().iter()`, `repo.find_reference(…)`, `repo.refs().set_referent()` | List/create/delete/rename branches. Read upstream via `branch.upstream()`. | +| **tag** | `repo.refs().set_referent()` (lightweight), `gix::Tag::write()` (annotated) | Create annotated tags with message + signer. | +| **checkout** | `repo.head().set_target()` + `gix-worktree-state::checkout()` | Update HEAD, write index, materialize files to working tree. | +| **restore** | `repo.checkout()` with paths or `gix-worktree-state` partial checkout | Restore working tree files from index or a tree-ish. | +| **reset** | `repo.head().set_target()` + `index.write_tree_from_diff()` | Soft/mixed/hard reset via ref update + index + worktree. | +| **clean** | `gix::dir::walk()` for listing, `std::fs::remove_file()` for removal | Use dirwalk to list untracked files, then delete them. | +| **mv** | `std::fs::rename()` + `index.remove()` + `index.add()` | Move file on disk, update index entries. | +| **init** | `gix::create::into()` or manual `.git/` creation | Create HEAD, `refs/heads/`, config with `gix-config`. | +| **clone** | `gix-protocol::fetch()` + `gix-worktree-state::checkout()` | Fetch from remote, checkout HEAD. Already works via `blocking-network-client`. | +| **fetch** | `gix-protocol::fetch()` + `gix-refspec::match_and_update()` | Fetch refs from remote, update remote tracking branches. | +| **push** | `gix-protocol::push()` | Push local refs to remote. Supports atomic push. | +| **remote** | `gix-config` read/write on `remote.*` keys | List/add/remove/rename remotes. | + +### ✅ Available by enabling gix feature flags + +Add features to the `gix` dependency in `Cargo.toml`: + +```toml +gix = { features = [ + "basic", "sha1", + "blocking-http-transport-reqwest-rust-tls", + "worktree-mutation", "status", + "blame", # adds gix-blame + "merge", # adds gix-merge (3-way tree merge) + "serde", # json serialization for gix types +] } +``` + +| Command | Feature flag | gix API | Notes | +|---|---|---|---| +| **blame** | `blame` | `gix::blame::Blob::blame()` | Line-by-line annotation. Supports textconv. | +| **merge** | `merge` | `gix::merge::merge_tree()` | 3-way tree-level merge (recursive strategy). Returns merge result with conflicts. | + +### ❌ Not available in gix + +These need either `jj-lib` or a hand-written implementation: + +| Command | Gap | Workaround | +|---|---|---| +| **rebase** | No sequencer/transaction engine | Hand-write a cherry-pick loop using `gix-merge` + `gix-object` commit creation (~100 lines) | +| **cherry-pick** | Same as rebase | `gix-merge` tree merge + create commit (~30 lines) | +| **stash** | Temporary commit + checkout + pop | Stash as a ref (`refs/stash`), push/pop with commit + reset (~60 lines) | +| **bisect** | No bisect state machine | Walk commits, binary search with ref updates (~80 lines) | +| **submodule** | `gix-submodule` is informational only | No easy solution without shelling out to `git` | + +--- + +## Why gix is the right choice for `gor status` + +### What we gain vs. jj-lib + +| Concern | jj-lib | gix | +|---|---|---| +| **Dependencies** | +30+ transitive crates | Already present | +| **Compile time** | + many seconds | Zero additional | +| **Async required** | Yes — need tokio runtime + block_on bridge | No — fully synchronous | +| **`.jj/` directory** | Created in user repo | Never touches `.jj/` | +| **Speed** | `snapshot()` walks full tree every time | Index-based with lstat caching — fast | +| **Stability** | 0.x, no stability guarantees | 0.85, mature, gitoxide project is well-established | + +### What we lose vs. jj-lib + +- **No rebase/cherry-pick** — but gor is a GitHub CLI, not a full git replacement +- **No conflict display** — `gix-merge` can detect them; display is straightforward +- **No revset language** — `gix-revision` gives merge-base and describe, which covers log use cases + +For `gor`'s mission as a **GitHub CLI**, the local git operations needed are: +`status` → `diff` → `add` → `commit` → `push` (and maybe `log` + `blame`). + +None of these require rebase, cherry-pick, or a change-based model. + +--- + +## gix API Quick Reference for status + +```rust +use gix::{Repository, Status}; + +// Open the repo +let repo = gix::discover(".")?; + +// Get working tree status +let status = repo.status(gix::worktree::Status::default())?; + +// Branch info +let head = repo.head()?; +let branch_name = head.name().map(|n| n.as_bstr().to_string()); +let upstream = repo + .find_reference(branch_name)? + .into_fully_peeled_id()? + .object()? + .into_commit(); + +// Ahead/behind +let graph = repo.graph()?; +let (ahead, behind) = graph.ahead_behind(&local_oid, &upstream_oid)?; + +// Staged changes (index vs HEAD) +let staged = status.staged()?; + +// Unstaged changes (working tree vs index) +let unstaged = status.unstaged()?; + +// Untracked files +let untracked = status.untracked()?; + +// Conflicted entries (from index) +let index = repo.index()?; +let conflicted: Vec<_> = index + .entries() + .iter() + .filter(|e| e.stage() > 0) + .map(|e| e.path().to_string()) + .collect(); +``` + +--- + +## Recommendation + +**Use `gix` as the sole VCS engine for `gor status` and all near-term git commands.** + +Add `jj-lib` later only if a specific command requires it (e.g., `gor rebase`). The gix-first approach: + +1. Requires zero new dependencies today +2. Has no `.jj/` directory side effects +3. Is fully synchronous (no tokio runtime) +4. Uses git's index-based caching (fast on large repos) +5. Is already proven in gor's existing clone/fetch code paths From 599b2b57b55cd8bb94adac038824b5506fd27bf4 Mon Sep 17 00:00:00 2001 From: Kerry Hatcher Date: Sun, 26 Jul 2026 17:35:08 -0400 Subject: [PATCH 2/4] feat(vcs): add gix-based VCS module with status support MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Introduce a new `gor::vcs` module backed by `gix` (gitoxide) for local git operations, beginning with working tree status. - GitRepo::open() discovers repos via gix::discover() (walks up dirs) - GitRepo::status() returns staged, unstaged, untracked, and conflicted files in a single index-based pass - Branch name and upstream tracking ref are extracted from HEAD config - All types derive serde::Serialize for --json output - Zero new dependencies — gix was already in Cargo.toml with "status" feature Phase 0 of the gor status implementation. --- _typos.toml | 1 + src/lib.rs | 1 + src/vcs/mod.rs | 22 +++++ src/vcs/repo.rs | 228 +++++++++++++++++++++++++++++++++++++++++++++++ src/vcs/types.rs | 75 ++++++++++++++++ 5 files changed, 327 insertions(+) create mode 100644 src/vcs/mod.rs create mode 100644 src/vcs/repo.rs create mode 100644 src/vcs/types.rs diff --git a/_typos.toml b/_typos.toml index 2857b08..59bce2f 100644 --- a/_typos.toml +++ b/_typos.toml @@ -13,6 +13,7 @@ ratatui = "ratatui" pitty = "pitty" worktree = "worktree" workt = "workt" +rela = "rela" [files] # Don't spellcheck lockfiles or build output. diff --git a/src/lib.rs b/src/lib.rs index 7c61dff..9b40995 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -38,6 +38,7 @@ pub mod host; pub mod keyring_store; pub mod output; pub mod repository; +pub mod vcs; use clap::Parser; use cli::Args; diff --git a/src/vcs/mod.rs b/src/vcs/mod.rs new file mode 100644 index 0000000..f7269aa --- /dev/null +++ b/src/vcs/mod.rs @@ -0,0 +1,22 @@ +//! Git VCS operations backed by `gix` (gitoxide). +//! +//! This module provides a pure-Rust interface to local git repositories, +//! wrapping the `gix` crate for operations like status, diff, log, and +//! other common git porcelain commands. +//! +//! # Examples +//! +//! ```no_run +//! use std::path::Path; +//! use gor::vcs::GitRepo; +//! +//! let repo = GitRepo::open(Path::new(".")).expect("should find a git repo"); +//! let status = repo.status().expect("status should succeed"); +//! println!("On branch: {:?}", status.branch); +//! ``` + +mod repo; +mod types; + +pub use repo::GitRepo; +pub use types::{ChangeType, FileStatus, VcsError, WorkingTreeStatus}; diff --git a/src/vcs/repo.rs b/src/vcs/repo.rs new file mode 100644 index 0000000..5eff4bb --- /dev/null +++ b/src/vcs/repo.rs @@ -0,0 +1,228 @@ +//! Git repository wrapper backed by `gix` (gitoxide). +//! +//! Provides a [`GitRepo`] struct that wraps a `gix::Repository` and +//! exposes high-level VCS operations like status. + +use std::path::Path; + +use gix::bstr::BString; +use gix::index::entry::Stage; + +use crate::vcs::types::{ChangeType, FileStatus, VcsError, WorkingTreeStatus}; + +/// A local git repository backed by `gix` (gitoxide). +/// +/// Uses [`gix::discover`] to find the repository root by walking up +/// parent directories from a given path. +/// +/// # Examples +/// +/// ```no_run +/// use std::path::Path; +/// use gor::vcs::GitRepo; +/// +/// let repo = GitRepo::open(Path::new(".")).expect("should find a git repo"); +/// let status = repo.status().expect("status should succeed"); +/// ``` +pub struct GitRepo { + repo: gix::Repository, +} + +impl GitRepo { + /// Open or discover a git repository from the given directory. + /// + /// Walks up parent directories looking for a `.git/` directory. + /// + /// # Errors + /// + /// Returns [`VcsError::NotARepository`] if no git repository is found. + /// Returns [`VcsError::Io`] if an I/O error occurs during discovery. + pub fn open(path: &Path) -> Result { + let repo = gix::discover(path).map_err(|e| VcsError::NotARepository(format!("{e}")))?; + Ok(Self { repo }) + } + + /// Get the full working tree status in a single pass. + /// + /// Returns staged, unstaged, untracked, and conflicted files along + /// with branch information. + /// + /// # Errors + /// + /// Returns [`VcsError::Other`] if a git operation fails. + /// Returns [`VcsError::Io`] if an I/O error occurs. + pub fn status(&self) -> Result { + let branch = self.get_branch_name(); + let (upstream, ahead, behind) = self.get_upstream_info(&branch); + let (staged, unstaged, untracked) = self.collect_status_changes()?; + let conflicted = self.get_conflicted_files()?; + + Ok(WorkingTreeStatus { + branch: if branch.is_empty() { + None + } else { + Some(branch) + }, + upstream, + ahead, + behind, + staged, + unstaged, + untracked, + conflicted, + }) + } + + /// Get the current branch name, or an empty string if detached HEAD. + fn get_branch_name(&self) -> String { + self.repo + .head() + .ok() + .map(|head| head.name().as_bstr().to_string()) + .filter(|s| !s.is_empty()) + .unwrap_or_default() + } + + /// Get upstream tracking info for the current branch. + fn get_upstream_info(&self, branch_name: &str) -> (Option, u32, u32) { + if branch_name.is_empty() { + return (None, 0, 0); + } + + let branch_ref = format!("refs/heads/{branch_name}"); + let Ok(reference) = self.repo.find_reference(&branch_ref) else { + return (None, 0, 0); + }; + + let upstream = self + .repo + .branch_remote_tracking_ref_name( + reference.inner.name.as_ref(), + gix::remote::Direction::Fetch, + ) + .and_then(Result::ok) + .map(|cow| cow.to_string()); + + // Ahead/behind requires commit walking; simplified to (0, 0) for now. + (upstream, 0, 0) + } + + /// Collect staged, unstaged, and untracked changes from the status iterator. + #[allow(clippy::type_complexity)] + fn collect_status_changes( + &self, + ) -> Result<(Vec, Vec, Vec), VcsError> { + let mut staged = Vec::new(); + let mut unstaged = Vec::new(); + let mut untracked = Vec::new(); + + let platform = self + .repo + .status(gix::progress::Discard) + .map_err(|e| VcsError::Other(format!("failed to create status platform: {e}")))?; + + let iter = platform + .into_iter(Vec::::new()) + .map_err(|e| VcsError::Other(format!("failed to create status iterator: {e}")))?; + + for item in iter { + let item = item.map_err(|e| VcsError::Other(format!("status iteration error: {e}")))?; + match item { + gix::status::Item::TreeIndex(change) => { + let location = change.location().to_string(); + let change_type = match change { + gix::diff::index::ChangeRef::Addition { .. } + | gix::diff::index::ChangeRef::Rewrite { .. } => ChangeType::Added, + gix::diff::index::ChangeRef::Deletion { .. } => ChangeType::Deleted, + gix::diff::index::ChangeRef::Modification { .. } => ChangeType::Modified, + }; + staged.push(FileStatus { + path: location, + status: change_type, + }); + } + gix::status::Item::IndexWorktree(wt_item) => { + Self::collect_worktree_item(wt_item, &mut unstaged, &mut untracked); + } + } + } + + Ok((staged, unstaged, untracked)) + } + + /// Process a single worktree status item, classifying it as unstaged or untracked. + fn collect_worktree_item( + item: gix::status::index_worktree::Item, + unstaged: &mut Vec, + untracked: &mut Vec, + ) { + match item { + gix::status::index_worktree::Item::Modification { + rela_path, status, .. + } => { + use gix::status::plumbing::index_as_worktree::{Change, EntryStatus}; + let path = rela_path.to_string(); + match status { + EntryStatus::Change(change) => { + let change_type = match change { + Change::Removed => ChangeType::Deleted, + Change::Modification { .. } | Change::SubmoduleModification(_) => { + ChangeType::Modified + } + Change::Type { .. } => ChangeType::TypeChange, + }; + unstaged.push(FileStatus { + path, + status: change_type, + }); + } + EntryStatus::IntentToAdd => { + unstaged.push(FileStatus { + path, + status: ChangeType::Added, + }); + } + EntryStatus::Conflict { .. } => { + untracked.push(path); + } + EntryStatus::NeedsUpdate(_) => { + // Stat cache refresh, no user-visible change + } + } + } + gix::status::index_worktree::Item::DirectoryContents { entry, .. } => { + if entry.status == gix::dir::entry::Status::Untracked { + untracked.push(entry.rela_path.to_string()); + } + } + gix::status::index_worktree::Item::Rewrite { + dirwalk_entry, + source, + .. + } => { + let path = dirwalk_entry.rela_path.to_string(); + let from = source.rela_path().to_string(); + unstaged.push(FileStatus { + path, + status: ChangeType::Renamed { from }, + }); + } + } + } + + /// Get the list of conflicted files from the index. + fn get_conflicted_files(&self) -> Result, VcsError> { + let index = self + .repo + .index() + .map_err(|e| VcsError::Other(format!("failed to open index: {e}")))?; + let state: &gix::index::State = &index; + let entries = state.entries(); + let conflicted: Vec = entries + .iter() + .filter(|e| e.stage() != Stage::Unconflicted) + .map(|e| e.path(state).to_string()) + .collect(); + Ok(conflicted) + } +} diff --git a/src/vcs/types.rs b/src/vcs/types.rs new file mode 100644 index 0000000..4234897 --- /dev/null +++ b/src/vcs/types.rs @@ -0,0 +1,75 @@ +//! Types for VCS (git) operations. +//! +//! Defines the status types and error types used by the VCS module. + +/// Errors from VCS operations. +#[derive(Debug, thiserror::Error)] +#[non_exhaustive] +pub enum VcsError { + /// Error when the directory is not a git repository. + #[error("not a git repository: {0}")] + NotARepository(String), + + /// A git operation failed with an unexpected error. + #[error("git operation failed: {0}")] + Other(String), + + /// IO error during file or network access. + #[error("I/O error: {0}")] + Io(#[from] std::io::Error), +} + +/// Complete working tree status. +#[derive(Debug, Clone, serde::Serialize)] +pub struct WorkingTreeStatus { + /// The name of the current branch (if any). + pub branch: Option, + /// The upstream tracking branch (if any). + pub upstream: Option, + /// Number of commits ahead of the upstream. + pub ahead: u32, + /// Number of commits behind the upstream. + pub behind: u32, + /// List of staged changes. + pub staged: Vec, + /// List of unstaged changes. + pub unstaged: Vec, + /// List of untracked files. + pub untracked: Vec, + /// List of conflicted files. + pub conflicted: Vec, +} + +/// A single changed file with its status. +#[derive(Debug, Clone, serde::Serialize)] +pub struct FileStatus { + /// Path to the file inside the repository. + pub path: String, + /// The type of change for this file. + #[serde(flatten)] + pub status: ChangeType, +} + +/// The type of change for a file. +#[derive(Debug, Clone, serde::Serialize)] +#[serde(tag = "status")] +pub enum ChangeType { + /// File has been modified. + #[serde(rename = "modified")] + Modified, + /// New file added to the index/working tree. + #[serde(rename = "added")] + Added, + /// File removed from the repository. + #[serde(rename = "deleted")] + Deleted, + /// File type changed (e.g., symlink vs regular file). + #[serde(rename = "typechange")] + TypeChange, + /// File was renamed or moved. + #[serde(rename = "renamed")] + Renamed { + /// The old path of the renamed file. + from: String, + }, +} From 7b40ac4c6b7c3847770f953f7363279f40d10d92 Mon Sep 17 00:00:00 2001 From: Kerry Hatcher Date: Sun, 26 Jul 2026 17:39:28 -0400 Subject: [PATCH 3/4] fix(vcs): disambiguate intra-doc link for gix::discover MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add parentheses to resolve ambiguous link in doc comment — gix::discover is both a function and a module, causing a rustdoc warning error. --- src/vcs/repo.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/vcs/repo.rs b/src/vcs/repo.rs index 5eff4bb..ed343cb 100644 --- a/src/vcs/repo.rs +++ b/src/vcs/repo.rs @@ -12,7 +12,7 @@ use crate::vcs::types::{ChangeType, FileStatus, VcsError, WorkingTreeStatus}; /// A local git repository backed by `gix` (gitoxide). /// -/// Uses [`gix::discover`] to find the repository root by walking up +/// Uses [`gix::discover()`] to find the repository root by walking up /// parent directories from a given path. /// /// # Examples From 3ace4a3c306b9947ecc123ca64a419f722e6becb Mon Sep 17 00:00:00 2001 From: Kerry Hatcher Date: Sun, 26 Jul 2026 17:56:56 -0400 Subject: [PATCH 4/4] =?UTF-8?q?fix(vcs):=20address=20CodeRabbit=20review?= =?UTF-8?q?=20=E2=80=94=20branch=20name,=20conflicted=20dedup,=20docs?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Fix get_branch_name() to use Head::referent_name() instead of Head::name() (the latter always returns "HEAD", not the branch) - Shorten upstream tracking ref names via FullNameRef::shorten() - Remove conflicted files from the untracked set (already reported separately via get_conflicted_files()) - Sort and deduplicate conflicted file paths (multiple index stages per path) - Update HANDOFF.md and research-gix-for-gor.md code examples to match the gix 0.85 platform iterator API --- HANDOFF.md | 20 +++++--- docs/research/research-gix-for-gor.md | 67 ++++++++++++++++----------- src/vcs/repo.rs | 17 ++++--- 3 files changed, 62 insertions(+), 42 deletions(-) diff --git a/HANDOFF.md b/HANDOFF.md index 79f40e3..2d60945 100644 --- a/HANDOFF.md +++ b/HANDOFF.md @@ -182,12 +182,14 @@ gix = { version = "0.85", default-features = false, features = [ should always be available. - **`gix::discover()` over `gix::open()`.** Walk up for `.git/` so `gor status` works from any subdirectory, just like `git status`. -- **Single `gix::Status` pass.** gix collects staged, unstaged, untracked, - and ignored in one directory walk. Don't reconstruct it with separate +- **Single `gix::status` platform pass.** Use `repo.status(Discard)` to + obtain a `Platform`, then consume it via `into_iter()`. The iterator + yields `Item::TreeIndex` (staged) and `Item::IndexWorktree` (unstaged + + untracked) in one pass. Don't try to reconstruct status with separate API calls. -- **Index-based, not snapshot-based.** `gix::Status` uses lstat to check - file modification times against the index, skipping unchanged files. - No full tree walk unless something changed. +- **Index-based, not snapshot-based.** The status platform uses lstat to + check file modification times against the index, skipping unchanged + files. No full tree walk unless something changed. - **Rename detection off by default.** `gix::diff::DetectRenames` is opt-in via `--renames` for performance. @@ -195,13 +197,17 @@ gix = { version = "0.85", default-features = false, features = [ - `gix::discover()` respects `$GIT_DIR` and `$GIT_DISCOVERY_ACROSS_FILESYSTEM`. Test edge cases with environment variables. +- **No `staged()/unstaged()/untracked()` accessors.** gix 0.85's status + API is iterator-based. Consume via `Platform::into_iter()` and classify + each `Item` by variant. - `gix::Status` returns paths relative to the working tree root, not the current directory. Normalize for display (like `git status` does). - Stat-caching means `gix` may miss files touched by external tools if mtime isn't updated. Falls back to content comparison. - `gix` 0.85 is still pre-1.0. Pin the exact version (already done). -- Conflicted files come from `gix::Index::entries()` with `stage() > 0`, - not from `gix::Status`. Need a separate index read. +- Conflicted files come from `gix::Index::entries()` with + `stage() != Stage::Unconflicted`, not from the status iterator. + Deduplicate via sort + dedup since multiple stages exist per path. ## First Steps for the Agent diff --git a/docs/research/research-gix-for-gor.md b/docs/research/research-gix-for-gor.md index e8c832b..0c9b190 100644 --- a/docs/research/research-gix-for-gor.md +++ b/docs/research/research-gix-for-gor.md @@ -153,44 +153,59 @@ None of these require rebase, cherry-pick, or a change-based model. ## gix API Quick Reference for status ```rust -use gix::{Repository, Status}; +use gix::bstr::BString; // Open the repo let repo = gix::discover(".")?; -// Get working tree status -let status = repo.status(gix::worktree::Status::default())?; - -// Branch info +// Branch info via Head::referent_name() let head = repo.head()?; -let branch_name = head.name().map(|n| n.as_bstr().to_string()); -let upstream = repo - .find_reference(branch_name)? - .into_fully_peeled_id()? - .object()? - .into_commit(); - -// Ahead/behind -let graph = repo.graph()?; -let (ahead, behind) = graph.ahead_behind(&local_oid, &upstream_oid)?; +let branch_name: String = head + .referent_name() + .map(|n| n.shorten().to_string()) + .unwrap_or_default(); -// Staged changes (index vs HEAD) -let staged = status.staged()?; - -// Unstaged changes (working tree vs index) -let unstaged = status.unstaged()?; - -// Untracked files -let untracked = status.untracked()?; +// Upstream tracking via config +let branch_ref = format!("refs/heads/{branch_name}"); +let upstream = repo + .find_reference(&branch_ref) + .ok() + .and_then(|r| { + repo.branch_remote_tracking_ref_name( + r.inner.name.as_ref(), + gix::remote::Direction::Fetch, + ) + .and_then(Result::ok) + .map(|cow| cow.shorten().to_string()) + }); + +// Status via Platform + into_iter() +let platform = repo.status(gix::progress::Discard)?; +let iter = platform.into_iter(Vec::::new())?; + +for item in iter { + let item = item?; + match item { + gix::status::Item::TreeIndex(change) => { + // Staged: change.location() + match variant + } + gix::status::Item::IndexWorktree(wt_item) => { + // Unstaged/untracked from wt_item + } + } +} // Conflicted entries (from index) let index = repo.index()?; -let conflicted: Vec<_> = index +let state: &gix::index::State = &index; +let mut conflicted: Vec<_> = state .entries() .iter() - .filter(|e| e.stage() > 0) - .map(|e| e.path().to_string()) + .filter(|e| e.stage() != gix::index::entry::Stage::Unconflicted) + .map(|e| e.path(state).to_string()) .collect(); +conflicted.sort(); +conflicted.dedup(); ``` --- diff --git a/src/vcs/repo.rs b/src/vcs/repo.rs index ed343cb..4a13774 100644 --- a/src/vcs/repo.rs +++ b/src/vcs/repo.rs @@ -78,8 +78,7 @@ impl GitRepo { self.repo .head() .ok() - .map(|head| head.name().as_bstr().to_string()) - .filter(|s| !s.is_empty()) + .and_then(|head| head.referent_name().map(|name| name.shorten().to_string())) .unwrap_or_default() } @@ -101,7 +100,7 @@ impl GitRepo { gix::remote::Direction::Fetch, ) .and_then(Result::ok) - .map(|cow| cow.to_string()); + .map(|cow| cow.shorten().to_string()); // Ahead/behind requires commit walking; simplified to (0, 0) for now. (upstream, 0, 0) @@ -182,11 +181,9 @@ impl GitRepo { status: ChangeType::Added, }); } - EntryStatus::Conflict { .. } => { - untracked.push(path); - } - EntryStatus::NeedsUpdate(_) => { - // Stat cache refresh, no user-visible change + EntryStatus::Conflict { .. } | EntryStatus::NeedsUpdate(_) => { + // Conflicts reported via get_conflicted_files(); + // stat updates are cache-internal. } } } @@ -218,11 +215,13 @@ impl GitRepo { .map_err(|e| VcsError::Other(format!("failed to open index: {e}")))?; let state: &gix::index::State = &index; let entries = state.entries(); - let conflicted: Vec = entries + let mut conflicted: Vec = entries .iter() .filter(|e| e.stage() != Stage::Unconflicted) .map(|e| e.path(state).to_string()) .collect(); + conflicted.sort(); + conflicted.dedup(); Ok(conflicted) } }