Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -15,5 +15,9 @@ testdir

# builds
src-go/git-cross-go
src-go/vendor/
src-rust/git-cross-rust
src-rust/target

# git-cross state directory
.cross/
279 changes: 199 additions & 80 deletions AGENTS.md

Large diffs are not rendered by default.

39 changes: 39 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,45 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

## [Unreleased]

### Fixed
- **P0: Sparse checkout broken on newer Git versions** - `git sparse-checkout set <path>` in `--no-cone` mode no longer reliably checks out directories without trailing `/`. Fixed across all three implementations (Go, Rust, Justfile.cross) by appending `/` to sparse-checkout patterns and using `git read-tree -mu HEAD` instead of bare `git checkout` (which can no-op after `--no-checkout`).
- **P0: Go build "inconsistent vendoring" in CI** - Tests that build the Go binary now remove stale `vendor/` directory and pass `-mod=mod` as a direct flag. Added `src-go/vendor/` to `.gitignore` to prevent accidental commits.
- **test/014_remove.sh robustness** - Go binary is now reused if already built; gracefully skips Go tests when Go toolchain is unavailable.

### Added
- **AI-assisted coding / sandbox workflow documentation** - New README section explaining how git-cross integrates with AI coding tools and container-based development sandboxes (`sbx`, Docker sandbox). Covers subfolder scoping, `Crossfile` reproducibility, and bidirectional sync.
- **test/018_sbx_sandbox.sh** - End-to-end test for the AI sandbox workflow: vendor setup, sandbox isolation (no `.git`), AI file modifications, diff review, upstream push, Crossfile replay, and sync.

## [0.3.0] - 2026-03-28

### Added
- **Context-aware `cross diff`** - Auto-detects current patch from CWD when no path argument given
- From inside `vendor/lib`: shows only that patch's diff
- From repo root: shows all diffs (Go/Rust) or requires explicit path (Just/Shell)
- From subdirectory of a patch: resolves to parent patch
- Implemented across all three implementations (Just, Go, Rust)
- New test `test/016_diff_context.sh` with 7 scenarios
- **Expanded test coverage** for Go and Rust CLIs
- `diff`, `replay`, `remove`, `prune` commands now tested in `test/008_rust_cli.sh` and `test/009_go_cli.sh`
- Output assertions for `list` and `status` commands
- Re-enabled `test/006_push.sh` with 4 test scenarios (basic, custom message, named branch, force push)
- Graceful skip on emulated ARM64 platforms where compiled binaries crash
- **P3 TODO item**: Auto-generate GitHub Release with changelog on version tags

### Fixed
- **Rust `exec` error handling** - Exit status is now properly propagated instead of silently discarded
- **Rust dead code cleanup** - Removed unused `--dry` flag and `shell-words` dependency
- **Go `go.mod` version** - Downgraded from unreleased 1.25.5 to 1.23 (matches CI workflow)
- **Justfile.cross `_resolve_context2`** - Fixed jq `startswith` logic (was checking wrong direction for parent path matching) and fixed `{{path}}` vs `$path` template/variable confusion
- **Justfile.cross CWD propagation** - Added `USER_CWD` env var to preserve caller's working directory through Just's CWD changes, enabling reliable CWD-based patch auto-detection
- **Justfile.cross push warning** - Changed stale "WORK IN PROGRESS" message to accurate "experimental" notice
- **Test 003** - Added missing `mkdir -p` for `src/lib2` upstream directory
- **Test 006** - Fixed Just positional parameter passing (bypasses `*ARGS` empty-string loss)
- **Test 017** - Fixed Go binary path (`git-cross` → `git-cross-go`) and Rust binary path (`release` → `debug`)

### Changed
- **fzf selection UX** - Added `--select-1`, `--exit-0`, and custom `--prompt` to Justfile fzf invocations; added `--header` and `--border` to Go/Rust fzf for consistent, cleaner selection UI

## [0.2.1] - 2026-01-06

### Added
Expand Down
1 change: 1 addition & 0 deletions Justfile
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import? "git.just"
[no-cd]
@cross *ARGS:
REPO_DIR=$(git rev-parse --show-toplevel) \
USER_CWD=${USER_CWD:-$(pwd)} \
just --justfile "{{source_dir()}}/Justfile.cross" {{ARGS}}

# keep compatibility with `just test-cross`
Expand Down
80 changes: 57 additions & 23 deletions Justfile.cross
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ set export := true
set positional-arguments

CROSSFILE := "Crossfile"
CROSSDIR := ".git/cross"
CROSSDIR := ".cross"
METADATA := "$CROSSDIR/metadata.json"
JUST_DIR := env("JUST_DIR", source_dir())
REPO_DIR := env ("REPO_DIR", "$(git rev-parse --show-toplevel)")
Expand Down Expand Up @@ -107,17 +107,28 @@ _resolve_context2 path="": check-initialized

# Resolve git repo relative path of target
if test -z "$path"
set -x path "$(git rev-parse --show-prefix | sed 's,\/$,,')" # cwd, relative to git repo
# Use USER_CWD (set by root Justfile cross recipe) to preserve caller's CWD
if test -n "$USER_CWD" -a -n "$REPO_DIR"
set -x path (realpath --relative-to="$REPO_DIR" "$USER_CWD" 2>/dev/null; or echo "")
# "." means we're at repo root — treat as empty
if test "$path" = "."
set -x path ""
end
end
if test -z "$path"
set -x path "$(git rev-parse --show-prefix | sed 's,\/$,,')"
end
end
if test -z "$path"
just cross _log error "Provide path to 'patch' or change directory into it."
exit 1
end
# Query metadata.json and export matching key as env variables
# Find patch where local_path matches rel_target or is a parent of rel_target
jq -r --arg path "{{path}}" '
# Find patch whose local_path is a prefix of (or equal to) the given path
# i.e. path starts with local_path — handles both exact match and subdirectory cases
jq -r --arg path "$path" '
.patches
| map(. as $patch | select($patch.local_path | startswith($path)))
| map(. as $patch | select($path | startswith($patch.local_path)))
| map(. + {mlen:(.local_path|length)})
| max_by(.mlen)
| to_entries | map("set -x \(.key) \(.value|@sh)") | .[]
Expand Down Expand Up @@ -205,12 +216,22 @@ remove path: check-deps
git worktree remove --force "$wt"
end

# 2. Remove from Crossfile
# 2. Remove from Crossfile (match patch lines where last field is the local_path)
just cross _log info "Removing from Crossfile..."
if test -f "{{CROSSFILE}}"
set tmp (mktemp)
grep -v "patch" "{{CROSSFILE}}" > "$tmp"
grep "patch" "{{CROSSFILE}}" | grep -v "$l_path" >> "$tmp"
while read -l line
# Only remove lines that are patch commands with this exact local_path as last field
set fields (string split ' ' -- (string trim $line))
set is_patch false
for f in $fields
test "$f" = "patch" && set is_patch true
end
if test "$is_patch" = true -a (count $fields) -gt 0 -a "$fields[-1]" = "$l_path"
continue # skip this line
end
echo "$line" >> "$tmp"
end < "{{CROSSFILE}}"
mv "$tmp" "{{CROSSFILE}}"
end

Expand Down Expand Up @@ -350,9 +371,10 @@ patch remote_spec local_path="": check-deps
end
end

# calculate hash/id
set hash (echo $l_path | md5sum | cut -d' ' -f1 | cut -c1-8)
set wt ".git/cross/worktrees/$remote"_"$hash"
# calculate hash/id — must match Go/Rust: SHA256(remote\0remote_path\0branch)[:8]
set hash (printf '%s\0%s\0%s' $remote $r_path $remote_branch | sha256sum 2>/dev/null; or printf '%s\0%s\0%s' $remote $r_path $remote_branch | shasum -a 256)
set hash (echo $hash | cut -d' ' -f1 | string sub -l 8)
set wt ".cross/worktrees/$remote"_"$hash"

# setup worktree
just cross _log info "Setting up worktree at $wt..."
Expand All @@ -361,17 +383,27 @@ patch remote_spec local_path="": check-deps
git fetch $remote $remote_branch
git worktree add --no-checkout -B "cross/$remote/$remote_branch/$hash" $wt "$remote/$remote_branch" >/dev/null 2>&1

# Sparse checkout
# Sparse checkout — trailing "/" ensures gitignore-style pattern
# reliably matches the directory in --no-cone mode.
git -C $wt sparse-checkout init --no-cone
git -C $wt sparse-checkout set $r_path
git -C $wt checkout
git -C $wt sparse-checkout set "$r_path/"
# read-tree explicitly populates worktree (bare checkout can no-op after --no-checkout)
git -C $wt read-tree -mu HEAD
end

# sync to local_path
just cross _log info "Syncing files to $l_path..."
mkdir -p $l_path
rsync -av --delete --exclude .git $wt/$r_path/ $l_path/

# Ensure .cross/ is in .gitignore
if not grep -qxF '.cross/' .gitignore 2>/dev/null
if test -f .gitignore; and test -s .gitignore; and not string match -q '' (tail -c1 .gitignore)
echo '' >> .gitignore
end
echo '.cross/' >> .gitignore
end

# Add local_path to git
git add $l_path

Expand Down Expand Up @@ -494,6 +526,8 @@ sync *path="": check-initialized
popd

# 3. Pull rebase from upstream
# Refresh index to avoid false conflicts from rsync mtime changes
git -C $worktree update-index --refresh -q 2>/dev/null; or true
just cross _log info "Pulling from upstream..."
if not git -C $worktree pull --rebase
just cross _log error "Conflict detected in $worktree. Please resolve manually."
Expand Down Expand Up @@ -624,7 +658,7 @@ push path="" branch="" force="false" yes="false" message="": check-initialized
|| { just cross _log error "Error: Could not resolve metadata for '$path'."; exit 1; }

pushd "{{REPO_DIR}}"
just cross _log warn "The 'push' command is currently WORK IN PROGRESS."
just cross _log warn "The 'push' command is experimental. Verify upstream changes after use."
if not test -d $worktree
just cross _log error "Error: Worktree not found. Run 'just patch' first."
exit 1
Expand Down Expand Up @@ -715,9 +749,9 @@ push path="" branch="" force="false" yes="false" message="": check-initialized
list: check-deps
#!/usr/bin/env fish
pushd "{{REPO_DIR}}" >/dev/null
if test -f .git/cross/metadata.json
if test -f .cross/metadata.json
# Get unique remotes used by patches
set used_remotes (jq -r '.patches[].remote' .git/cross/metadata.json | sort -u)
set used_remotes (jq -r '.patches[].remote' .cross/metadata.json | sort -u)

if test (count $used_remotes) -gt 0
just cross _log info "Configured Remotes:"
Expand Down Expand Up @@ -761,8 +795,8 @@ list: check-deps
printf "%-20s %-30s %-20s\n" "REMOTE" "REMOTE PATH" "LOCAL PATH"
printf "%s\n" (string repeat -n 70 "-")

if test -f .git/cross/metadata.json
jq -r '.patches[] | "\(.remote) \(.remote_path) \(.local_path)"' .git/cross/metadata.json | while read -l remote rpath lpath
if test -f .cross/metadata.json
jq -r '.patches[] | "\(.remote) \(.remote_path) \(.local_path)"' .cross/metadata.json | while read -l remote rpath lpath
printf "%-20s %-30s %-20s\n" $remote $rpath $lpath
end
else
Expand Down Expand Up @@ -812,7 +846,7 @@ wt path="":
if test -n "{{path}}"
just cross _open_shell worktree "{{path}}"
else
set -l selected (just cross list | tail -n +3 | fzf --height 40% 2>/dev/null | awk '{print $NF}')
set -l selected (just cross list | tail -n +3 | fzf --height 40% --select-1 --exit-0 --prompt "Select patch (wt)> " 2>/dev/null | awk '{print $NF}')
test -z "$selected" && exit 0
just cross _resolve_context2 "$selected" | source || exit 1
set -l rel_dir (realpath -m --relative-to=$PWD {{REPO_DIR}}/$worktree)
Expand All @@ -829,7 +863,7 @@ cd path="":
if test -n "{{path}}"
just cross _open_shell local_path "{{path}}"
else
set -l selected (just cross list | tail -n +3 | fzf --height 40% 2>/dev/null | awk '{print $NF}')
set -l selected (just cross list | tail -n +3 | fzf --height 40% --select-1 --exit-0 --prompt "Select patch (cd)> " 2>/dev/null | awk '{print $NF}')
test -z "$selected" && exit 0
just cross _resolve_context2 "$selected" | source || exit 1
set -l rel_dir (realpath -m --relative-to=$PWD {{REPO_DIR}}/$local_path)
Expand All @@ -851,8 +885,8 @@ status: check-deps
printf "%-20s %-15s %-15s %-15s\n" "LOCAL PATH" "DIFF" "UPSTREAM" "CONFLICTS"
printf "%s\n" (string repeat -n 70 "-")

if test -f .git/cross/metadata.json
jq -r '.patches[] | "\(.remote) \(.remote_path) \(.local_path) \(.worktree)"' .git/cross/metadata.json | while read -l remote rpath local_path wt
if test -f .cross/metadata.json
jq -r '.patches[] | "\(.remote) \(.remote_path) \(.local_path) \(.worktree)"' .cross/metadata.json | while read -l remote rpath local_path wt
set diff_stat "Clean"
set upstream_stat "Synced"
set conflict_stat "No"
Expand Down
42 changes: 41 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@

[![CI](https://github.com/epcim/git-cross/workflows/CI/badge.svg)](https://github.com/epcim/git-cross/actions/workflows/ci.yml)
[![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](https://opensource.org/licenses/MIT)
[![Version](https://img.shields.io/badge/version-0.2.1-blue.svg)](https://github.com/epcim/git-cross/blob/main/CHANGELOG.md)
[![Version](https://img.shields.io/badge/version-0.3.0-blue.svg)](https://github.com/epcim/git-cross/blob/main/CHANGELOG.md)

**Git's CRISPR.** Minimalist approach for mixing "parts" of git repositories using `git worktree` + `rsync`.

Expand Down Expand Up @@ -160,6 +160,46 @@ If using `just`, you can override targets to add pre/post hooks:
3. **Rsync**: Efficiently syncs changes between worktree and your source tree.
4. **Crossfile**: A plain-text record of all active patches for easy sharing.

## AI-Assisted Coding and Sandbox Workflows

AI coding tools (Cursor, Copilot Workspace, Claude Code, Aider, etc.) frequently work in **subfolders** rather than the repository root. This is by design: the main `.git/` directory and full repository history are not shared with the AI tool's context, reducing noise and improving focus.

**git-cross fits this pattern naturally.** Vendored files are physical files in subfolders -- AI tools can read, modify, and reason about them directly without needing access to the upstream `.git` state.

### Docker Sandbox (`sbx`) Integration

Container-based development sandboxes (e.g. `docker sandbox`, `sbx`) create isolated environments where your code runs inside a container. These tools often support `git worktree` to share repository state without copying `.git/`:

```bash
# 1. Set up git-cross in your main repo
git cross use upstream https://github.com/example/lib.git
git cross patch upstream:src vendor/lib

# 2. Create a sandbox scoped to the vendor subfolder
sbx create --mount vendor/lib # AI tool sees only vendor/lib/

# 3. AI modifies files in the sandbox (vendor/lib/)
# 4. Push changes back upstream from the host
git cross push vendor/lib
```

**Key properties that make this work:**
- **Physical files** in subfolders (not gitlinks) -- sandbox tools mount them directly.
- **Sparse checkout** -- only the needed subdirectory is present, keeping AI context small.
- **`Crossfile` reproducibility** -- `git cross replay` reconstructs the vendored environment inside a fresh container or CI job.
- **Bidirectional sync** -- AI-generated changes in the sandbox flow back upstream via `git cross push`.
- **Hidden worktrees** -- the `.git/cross/worktrees/` directory stays on the host; the sandbox only sees clean working copies.

### Rules for AI-Assisted Development

When using AI tools with git-cross managed subfolders:

1. **Scope AI context to subfolders.** Share `vendor/<name>/` with the AI tool, not the repository root. The AI doesn't need `.git/`, `Crossfile`, or `.cross/`.
2. **Use `git cross diff` to review AI changes** before pushing upstream. This compares the local subfolder against the worktree (upstream state).
3. **Use `git cross sync` after upstream changes** to pull updates into the AI's working directory.
4. **`Crossfile` is the source of truth.** When setting up a new sandbox or CI environment, `git cross replay` recreates all patches from scratch.
5. **Worktrees enable container-aware git.** Tools like `sbx` that support `git worktree` can access the repository's git state without mounting the entire `.git/` directory into the container.

## Architecture

### Technical Implementation Analysis
Expand Down
Loading
Loading