From 87dcdfbe98a78d265fd8252e4c78f13064255c48 Mon Sep 17 00:00:00 2001 From: Test User Date: Wed, 27 May 2026 13:59:05 +0200 Subject: [PATCH 1/3] commit open work from previous session --- .gitignore | 6 +++ AGENTS.md | 16 ++++---- CHANGELOG.md | 2 + Justfile.cross | 46 ++++++++++++++++------ TODO.md | 6 +++ src-go/main.go | 62 +++++++++++++++++++++--------- src-rust/src/main.rs | 69 ++++++++++++++++++++++----------- test/009_go_cli.sh | 26 +++++++++++++ test/015_prune.sh | 90 ++++++++++++++++++++++++++++++++++++++++++++ 9 files changed, 266 insertions(+), 57 deletions(-) diff --git a/.gitignore b/.gitignore index cd3c7e0a6..fa51011dc 100644 --- a/.gitignore +++ b/.gitignore @@ -9,6 +9,12 @@ testdir # allow !.github !.specify +!.claude +!.claude/** +!.agents +!.agents/** +!.opencode +!.opencode/** !.gitignore !.goreleaser.yaml diff --git a/AGENTS.md b/AGENTS.md index 43b063f3a..d69e93d94 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -12,7 +12,7 @@ Instructions for AI agents working on this codebase. | **Just + Fish** | `Justfile.cross` | Fish/Bash | Just | `git` CLI + `jq` | Reference | | **Rust** (experimental) | `src-rust/src/main.rs` | Rust | Clap | `git2` (minimal) + `duct` | WIP | -Both Go and Rust are single-file implementations (~1500 lines each). Justfile.cross is ~940 lines. +Both Go and Rust are single-file implementations (~1500 lines each). Justfile.cross is ~960 lines. ## File Map @@ -21,7 +21,7 @@ Justfile # Root wrapper: delegates `just cross ` to Justfile.c Justfile.cross # Shell/Fish reference implementation (all commands) Crossfile # User-facing state: records use/patch commands for replay src-go/main.go # Go CLI (single file, Cobra-based) -src-go/go.mod # Go module (go 1.23) +src-go/go.mod # Go module (go 1.24) src-rust/src/main.rs # Rust CLI (single file, Clap-based) src-rust/Cargo.toml # Rust dependencies .git/cross/ # Internal state directory @@ -29,7 +29,7 @@ src-rust/Cargo.toml # Rust dependencies worktrees/ # Hidden git worktrees for each patch test/common.sh # Shared test helpers (setup_sandbox, create_upstream, assertions) test/run-all.sh # Test runner: discovers and executes test/NNN_*.sh files -test/001-017_*.sh # Individual test files (see coverage matrix below) +test/001-018_*.sh # Individual test files (see coverage matrix below) ``` ## Commands (all implementations) @@ -67,7 +67,7 @@ test/001-017_*.sh # Individual test files (see coverage matrix below) | Topic | Just/Fish | Go | Rust | |-------|-----------|-----|------| -| **Worktree hash** | `md5sum(local_path)` — branch NOT included | `SHA256(remote+path+branch)[:8]` — no field separator | `DefaultHasher(canonical+branch)[:8]` — **non-deterministic across Rust versions** | +| **Worktree hash** | `SHA256(remote\0remote_path\0branch)[:8]` — matches Go | `SHA256(remote+path+branch)[:8]` — no field separator | `DefaultHasher(canonical+branch)[:8]` — **non-deterministic across Rust versions** | | **Git interface** | Direct `git` CLI everywhere | Mixed: `gogs/git-module` + `os/exec` + `git.Open()` — 3 styles interleaved | `git2` for 2 commands only, `duct`/`run_cmd` for rest — git2 is dead weight | | **Metadata `id` field** | Written via jq | **Not in struct** — silently dropped on save | Present with `#[serde(default)]` | | **Crossfile removal** | `grep -v "patch"` — removes ALL lines with "patch" | `strings.Contains(line, "patch") && strings.Contains(line, localPath)` — fragile | Same as Go — fragile substring match | @@ -92,7 +92,7 @@ test/001-017_*.sh # Individual test files (see coverage matrix below) ### Test Structure -Tests 001-007 form a chain for Shell/Just (001 is sourced by 002, etc.). Tests 008 (Rust) and 009 (Go) are self-contained monolithic tests. Tests 010-017 are focused feature tests. +Tests 001-007 form a chain for Shell/Just (001 is sourced by 002, etc.). Tests 008 (Rust) and 009 (Go) are self-contained monolithic tests. Tests 010-018 are focused feature tests. ### Coverage Matrix @@ -179,7 +179,9 @@ Tests 001-007 form a chain for Shell/Just (001 is sourced by 002, etc.). Tests 0 - Tests 008/009 are self-contained and build their own binaries. - To run a specific test: `bash test/NNN_name.sh`. - To run all: `bash test/run-all.sh` or `just cross-test`. -- CI runs on `ubuntu-latest` and does NOT install Go/Rust toolchains — tests 008/009 must handle this gracefully (skip with message). +- CI runs on `ubuntu-latest` and does NOT install Go/Rust toolchains — tests 008/009/011 must handle this gracefully (skip with message if build fails). +- Go binary builds must remove stale `src-go/vendor/` before building and pass `-mod=mod` directly as a flag (not via `GOFLAGS`). The vendor directory is gitignored but may exist locally. +- Rust tests must handle `cargo build` failures gracefully — skip instead of failing the test. ### Commit Conventions @@ -191,7 +193,7 @@ Tests 001-007 form a chain for Shell/Just (001 is sourced by 002, etc.). Tests 0 ## Implementation Details - **Hidden worktrees**: `.git/cross/worktrees/_`. -- **Sparse checkout**: `git sparse-checkout set ` — only specified paths checked out. +- **Sparse checkout**: `git sparse-checkout init --no-cone` + `git sparse-checkout set /` + `git read-tree -mu HEAD`. The trailing `/` on the pattern is required for reliable directory matching in `--no-cone` mode on newer Git versions (2.43+). `read-tree -mu HEAD` explicitly materializes the worktree (bare `git checkout` can no-op after `--no-checkout`). - **Rsync**: `rsync -av --delete --exclude .git` for worktree-to-local sync. `--delete` removes files locally that were deleted upstream. - **Crossfile format**: Lines like `cross use ` or `cross patch :: `. Parsed as bash during `replay`. - **Metadata format**: JSON at `.git/cross/metadata.json`. Schema: `{"patches": [{"id", "remote", "remote_path", "local_path", "worktree", "branch"}]}`. diff --git a/CHANGELOG.md b/CHANGELOG.md index 81d6f0807..020f3a05c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,6 +10,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Fixed - **P0: Sparse checkout broken on newer Git versions** - `git sparse-checkout set ` 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. +- **Whole-repo patching (`:/` and `:.`)** - `cross patch remote:/` and `cross patch remote:.` now correctly patch the entire upstream repo. Previously `/` was rejected as invalid, and `.` produced an empty worktree because sparse-checkout pattern `./` matched nothing. Fix: `/` is normalized to `.`, and `.` skips sparse-checkout entirely (full checkout). +- **Prune leaves orphaned `.cross/worktrees/` directories** - `cross prune` now scans `.cross/worktrees/` and removes directories not referenced in `metadata.json`. Previously only `git worktree prune` was called, which cleans git's internal registry but not the actual cross-managed directories. - **test/014_remove.sh robustness** - Go binary is now reused if already built; gracefully skips Go tests when Go toolchain is unavailable. ### Added diff --git a/Justfile.cross b/Justfile.cross index e60fbcc99..c3170714b 100644 --- a/Justfile.cross +++ b/Justfile.cross @@ -320,6 +320,19 @@ prune remote_name="": check-deps # Always prune stale worktrees just cross _log info "Pruning stale worktrees..." git worktree prune --verbose + + # Clean orphaned .cross/worktrees/ directories not referenced in metadata + if test -d .cross/worktrees + set known_wts (jq -r '.patches[].worktree' {{METADATA}} 2>/dev/null) + for wt_dir in .cross/worktrees/*/ + set wt_dir (string trim -r -c '/' "$wt_dir") + if not contains "$wt_dir" $known_wts + just cross _log info "Removing orphaned worktree directory: $wt_dir" + rm -r "$wt_dir" + end + end + end + just cross _log success "Worktree pruning complete." end popd @@ -343,10 +356,19 @@ patch remote_spec local_path="": check-deps exit 1 end - # update vars + # update vars — treat "/" as "." (whole repo root) set r_path "$remote_path" + set r_path (string trim -l -c '/' "$r_path") + set r_path (string trim -r -c '/' "$r_path") + if test -z "$r_path" + set r_path "." + end set l_path "{{local_path}}" if test -z "$l_path" + if test "$r_path" = "." + just cross _log error "Error: local_path is required when patching whole repo (path is '.')" + exit 1 + end set l_path "$r_path" end @@ -357,11 +379,6 @@ patch remote_spec local_path="": check-deps just cross _log error "Error: Remote $remote not found. Run: just use $remote " exit 1 end - # validate paths - if test -z "$l_path"; or test "$l_path" = "/"; - just cross _log error "Error: Invalid format Use remote[:branch]:remote_path [local_path]" - exit 1 - end # validate target branch if test -z "$remote_branch" @@ -383,12 +400,17 @@ 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 — 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/" - # read-tree explicitly populates worktree (bare checkout can no-op after --no-checkout) - git -C $wt read-tree -mu HEAD + if test "$r_path" = "." + # Whole-repo patch: skip sparse-checkout, do full checkout + git -C $wt read-tree -mu HEAD + else + # 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/" + # read-tree explicitly populates worktree (bare checkout can no-op after --no-checkout) + git -C $wt read-tree -mu HEAD + end end # sync to local_path diff --git a/TODO.md b/TODO.md index 8082d0609..b2d0c2273 100644 --- a/TODO.md +++ b/TODO.md @@ -79,6 +79,12 @@ - [ ] **Single file patch capability** - Review and propose implementation (tool and test) to be able to patch even single file. If not easily possible without major refactoring, evaluate new command "patch-file". - **Effort:** 4-6 hours (includes research) + +- [ ] **Root overlay patching with `.crossignore`** - Support patching upstream root or upstream subdirectory into local repo root while preserving local-only files and preventing secret leakage upstream. + - **Problem:** Current patch/sync/push flows are mirror-oriented and unsafe for repo-root usage. + - **Requirements:** Safe root-target patching, `.crossignore` protection, non-destructive `remove`/`prune`, selective and batch-safe operations. + - **Spec:** `specs/002-root-overlay-patching/` + - **Effort:** 1-2 days for design and safety groundwork, plus implementation/testing across all three implementations. - [x] **Improve interactive `fzf` selection** in native implementations - Added `--header`, `--border`, `--select-1`, `--exit-0`, custom prompts. - **Effort:** 1 hour (completed) diff --git a/src-go/main.go b/src-go/main.go index 85674420c..7396c5f57 100644 --- a/src-go/main.go +++ b/src-go/main.go @@ -90,8 +90,9 @@ func parsePatchSpec(spec string) (patchSpec, error) { remotePath = strings.TrimPrefix(remotePath, "/") remotePath = strings.TrimSuffix(remotePath, "/") + // Treat empty path (from "/") as "." meaning whole repo root if remotePath == "" { - return patchSpec{}, fmt.Errorf("invalid remote path in spec: %s", spec) + remotePath = "." } return patchSpec{ @@ -664,22 +665,29 @@ func main() { return fmt.Errorf("git worktree add failed: %v\nOutput: %s", err, string(out)) } - // Sparse checkout — use trailing "/" so gitignore-style patterns - // reliably match the directory and its contents in --no-cone mode. - if _, err := git.NewCommand("sparse-checkout", "init", "--no-cone").RunInDir(wtDir); err != nil { - return fmt.Errorf("sparse-checkout init failed: %w", err) - } - sparsePattern := spec.RemotePath - if !strings.HasSuffix(sparsePattern, "/") { - sparsePattern += "/" - } - if _, err := git.NewCommand("sparse-checkout", "set", sparsePattern).RunInDir(wtDir); err != nil { - return fmt.Errorf("sparse-checkout set failed: %w", err) - } - // Use read-tree to explicitly populate index+worktree from HEAD, - // because bare "git checkout" can be a no-op after --no-checkout. - if _, err := git.NewCommand("read-tree", "-mu", "HEAD").RunInDir(wtDir); err != nil { - return fmt.Errorf("checkout failed: %w", err) + if spec.RemotePath == "." { + // Whole-repo patch: skip sparse-checkout, do full checkout + if _, err := git.NewCommand("read-tree", "-mu", "HEAD").RunInDir(wtDir); err != nil { + return fmt.Errorf("checkout failed: %w", err) + } + } else { + // Sparse checkout — use trailing "/" so gitignore-style patterns + // reliably match the directory and its contents in --no-cone mode. + if _, err := git.NewCommand("sparse-checkout", "init", "--no-cone").RunInDir(wtDir); err != nil { + return fmt.Errorf("sparse-checkout init failed: %w", err) + } + sparsePattern := spec.RemotePath + if !strings.HasSuffix(sparsePattern, "/") { + sparsePattern += "/" + } + if _, err := git.NewCommand("sparse-checkout", "set", sparsePattern).RunInDir(wtDir); err != nil { + return fmt.Errorf("sparse-checkout set failed: %w", err) + } + // Use read-tree to explicitly populate index+worktree from HEAD, + // because bare "git checkout" can be a no-op after --no-checkout. + if _, err := git.NewCommand("read-tree", "-mu", "HEAD").RunInDir(wtDir); err != nil { + return fmt.Errorf("checkout failed: %w", err) + } } } @@ -1504,6 +1512,26 @@ patch's diff. Otherwise shows diffs for all patches.`, // Always prune stale worktrees logInfo("Pruning stale worktrees...") git.NewCommand("worktree", "prune", "--verbose").RunInDir(".") + + // Clean orphaned .cross/worktrees/ directories not referenced in metadata + crossWtDir := ".cross/worktrees" + if entries, err := os.ReadDir(crossWtDir); err == nil { + knownWts := make(map[string]bool) + for _, p := range meta.Patches { + knownWts[p.Worktree] = true + } + for _, e := range entries { + if !e.IsDir() { + continue + } + wtPath := filepath.Join(crossWtDir, e.Name()) + if !knownWts[wtPath] { + logInfo(fmt.Sprintf("Removing orphaned worktree directory: %s", wtPath)) + os.RemoveAll(wtPath) + } + } + } + logSuccess("Worktree pruning complete.") } diff --git a/src-rust/src/main.rs b/src-rust/src/main.rs index 34328cc98..d2b0590e6 100644 --- a/src-rust/src/main.rs +++ b/src-rust/src/main.rs @@ -163,9 +163,12 @@ fn parse_patch_spec(spec: &str) -> Result { .trim_start_matches('/') .trim_end_matches('/') .to_string(); - if remote_path.is_empty() { - return Err(anyhow!("Invalid remote path in spec: {}", spec)); - } + // Treat empty path (from "/") as "." meaning whole repo root + let remote_path = if remote_path.is_empty() { + ".".to_string() + } else { + remote_path + }; Ok(PatchSpec { remote, @@ -821,25 +824,30 @@ fn main() -> Result<()> { return Err(e); } - // Sparse checkout — use trailing "/" so gitignore-style patterns - // reliably match the directory and its contents in --no-cone mode. - run_cmd(&["git", "-C", &wt_dir, "sparse-checkout", "init", "--no-cone"])?; - let sparse_pattern = if spec.remote_path.ends_with('/') { - spec.remote_path.clone() + if spec.remote_path == "." { + // Whole-repo patch: skip sparse-checkout, do full checkout + run_cmd(&["git", "-C", &wt_dir, "read-tree", "-mu", "HEAD"])?; } else { - format!("{}/", spec.remote_path) - }; - run_cmd(&[ - "git", - "-C", - &wt_dir, - "sparse-checkout", - "set", - &sparse_pattern, - ])?; - // Use read-tree to explicitly populate index+worktree from HEAD, - // because bare "git checkout" can be a no-op after --no-checkout. - run_cmd(&["git", "-C", &wt_dir, "read-tree", "-mu", "HEAD"])?; + // Sparse checkout — use trailing "/" so gitignore-style patterns + // reliably match the directory and its contents in --no-cone mode. + run_cmd(&["git", "-C", &wt_dir, "sparse-checkout", "init", "--no-cone"])?; + let sparse_pattern = if spec.remote_path.ends_with('/') { + spec.remote_path.clone() + } else { + format!("{}/", spec.remote_path) + }; + run_cmd(&[ + "git", + "-C", + &wt_dir, + "sparse-checkout", + "set", + &sparse_pattern, + ])?; + // Use read-tree to explicitly populate index+worktree from HEAD, + // because bare "git checkout" can be a no-op after --no-checkout. + run_cmd(&["git", "-C", &wt_dir, "read-tree", "-mu", "HEAD"])?; + } } log_info(&format!("Syncing files to {}...", target_path)); @@ -1369,6 +1377,25 @@ fn main() -> Result<()> { // Always prune stale worktrees log_info("Pruning stale worktrees..."); let _ = run_cmd(&["git", "worktree", "prune", "--verbose"]); + + // Clean orphaned .cross/worktrees/ directories not referenced in metadata + let cross_wt_dir = Path::new(".cross/worktrees"); + if cross_wt_dir.is_dir() { + let known_wts: std::collections::HashSet = + metadata.patches.iter().map(|p| p.worktree.clone()).collect(); + if let Ok(entries) = fs::read_dir(cross_wt_dir) { + for entry in entries.flatten() { + if entry.path().is_dir() { + let wt_path = format!(".cross/worktrees/{}", entry.file_name().to_string_lossy()); + if !known_wts.contains(&wt_path) { + log_info(&format!("Removing orphaned worktree directory: {}", wt_path)); + let _ = fs::remove_dir_all(entry.path()); + } + } + } + } + } + log_success("Worktree pruning complete."); } } diff --git a/test/009_go_cli.sh b/test/009_go_cli.sh index d6de851e3..495ce197d 100755 --- a/test/009_go_cli.sh +++ b/test/009_go_cli.sh @@ -111,6 +111,32 @@ if [ ! -f "vendor/nested-dir/file.txt" ]; then fail "Go 'patch' failed to vendor nested dir" fi +log_header "Testing Go 'patch' with whole repo root (.: and :/)..." +# Create a small upstream to patch entirely +root_upstream=$(create_upstream "root-demo") +pushd "$root_upstream" >/dev/null +echo "root file" > root.txt +mkdir -p sub +echo "sub file" > sub/data.txt +git add . && git commit -m "Root content" -q +popd >/dev/null + +"$GO_BIN" use root-demo "file://$root_upstream" +"$GO_BIN" patch root-demo:. vendor/whole-repo +if [ ! -f "vendor/whole-repo/root.txt" ]; then + fail "Go 'patch' with '.' failed to vendor root.txt" +fi +if [ ! -f "vendor/whole-repo/sub/data.txt" ]; then + fail "Go 'patch' with '.' failed to vendor sub/data.txt" +fi + +# Also test :/ syntax (should be normalized to .) +"$GO_BIN" patch root-demo:/ vendor/whole-repo-slash +if [ ! -f "vendor/whole-repo-slash/root.txt" ]; then + fail "Go 'patch' with '/' failed to vendor root.txt" +fi +"$GO_BIN" remove vendor/whole-repo-slash + # FIXME: cd and wt commands with --dry flag hang in CI environment # These commands work locally but cause test timeouts in automated runs # Skipping for now - comprehensive testing in test/010_worktree.sh diff --git a/test/015_prune.sh b/test/015_prune.sh index e59f124ea..143ac4e14 100755 --- a/test/015_prune.sh +++ b/test/015_prune.sh @@ -124,4 +124,94 @@ fi log_success "Test 3 passed: Worktree pruning completed" +###### +# Test 4: Orphaned .cross/worktrees/ directory cleanup (Shell/Just) +###### +log_header "Test 4: Orphaned worktree directory cleanup (Shell)..." + +setup_sandbox +cd "$SANDBOX" + +upstream4=$(create_upstream "upstream4") +mkdir -p "$upstream4/src" +pushd "$upstream4" >/dev/null +echo "content" > src/file.txt +git add src/file.txt && git commit -m "init" -q +popd >/dev/null + +just cross use demo "file://$upstream4" || fail "Failed to add remote" +just cross patch demo:src vendor/src || fail "Failed to create patch" + +# Verify active worktree exists +active_wt=$(find .cross/worktrees -maxdepth 1 -type d -name "demo_*" | head -n 1) +if [ -z "$active_wt" ]; then fail "Active worktree not found"; fi +log_info "Active worktree: $active_wt" + +# Create orphaned worktree directories (simulating stale state from hash changes or manual ops) +mkdir -p .cross/worktrees/demo_deadbeef +echo "orphan" > .cross/worktrees/demo_deadbeef/dummy.txt +mkdir -p .cross/worktrees/old-remote_12345678 + +orphan_count=$(find .cross/worktrees -maxdepth 1 -type d ! -name worktrees | wc -l | tr -d ' ') +log_info "Worktree dirs before prune: $orphan_count (expect 3: 1 active + 2 orphans)" + +# Run prune (no-arg mode, pipe 'n' to skip interactive remote removal) +echo "n" | just cross prune || true + +# Verify orphaned dirs are removed but active one remains +if [ -d ".cross/worktrees/demo_deadbeef" ]; then fail "Orphaned demo_deadbeef not cleaned"; fi +if [ -d ".cross/worktrees/old-remote_12345678" ]; then fail "Orphaned old-remote_12345678 not cleaned"; fi +if [ ! -d "$active_wt" ]; then fail "Active worktree $active_wt was incorrectly removed"; fi + +log_success "Test 4 passed: Orphaned worktree directories cleaned by Shell prune" + +###### +# Test 5: Orphaned .cross/worktrees/ directory cleanup (Go) +###### +log_header "Test 5: Orphaned worktree directory cleanup (Go)..." + +GO_BIN="$REPO_ROOT/src-go/git-cross-go" +if [ ! -f "$GO_BIN" ]; then + if command -v go >/dev/null 2>&1; then + rm -r "$REPO_ROOT/src-go/vendor" 2>/dev/null || true + ( cd "$REPO_ROOT/src-go" && CGO_ENABLED=0 go build -mod=mod -tags purego -o git-cross-go main.go ) || GO_BIN="" + else + GO_BIN="" + fi +fi + +if [ -n "$GO_BIN" ] && [ -f "$GO_BIN" ]; then + setup_sandbox + cd "$SANDBOX" + + upstream5=$(create_upstream "upstream5") + mkdir -p "$upstream5/lib" + pushd "$upstream5" >/dev/null + echo "content" > lib/file.txt + git add lib/file.txt && git commit -m "init" -q + popd >/dev/null + + "$GO_BIN" init + "$GO_BIN" use demo "file://$upstream5" + "$GO_BIN" patch demo:lib vendor/lib + + active_wt=$(find .cross/worktrees -maxdepth 1 -type d -name "demo_*" | head -n 1) + if [ -z "$active_wt" ]; then fail "Active worktree not found (Go)"; fi + + # Create orphaned dirs + mkdir -p .cross/worktrees/demo_deadbeef + mkdir -p .cross/worktrees/stale_aabbccdd + + # Go prune (no-arg, pipe 'n' for interactive prompt) + echo "n" | "$GO_BIN" prune + + if [ -d ".cross/worktrees/demo_deadbeef" ]; then fail "Go: orphaned demo_deadbeef not cleaned"; fi + if [ -d ".cross/worktrees/stale_aabbccdd" ]; then fail "Go: orphaned stale_aabbccdd not cleaned"; fi + if [ ! -d "$active_wt" ]; then fail "Go: active worktree incorrectly removed"; fi + + log_success "Test 5 passed: Orphaned worktree directories cleaned by Go prune" +else + echo "SKIP: Go binary not available, skipping Go orphan prune test" +fi + log_success "All prune tests passed!" From 9ac5a194527235332702949a881bb0298afb4c93 Mon Sep 17 00:00:00 2001 From: Test User Date: Wed, 27 May 2026 13:59:54 +0200 Subject: [PATCH 2/3] add skills --- .agents/README.md | 6 ++++ .agents/feature-spec-writer.md | 29 +++++++++++++++++ .agents/git-cross-maintainer.md | 31 +++++++++++++++++++ .agents/regression-tester.md | 26 ++++++++++++++++ .agents/safety-reviewer.md | 28 +++++++++++++++++ .claude/README.md | 6 ++++ .claude/commands.md | 15 +++++++++ .claude/skills.md | 21 +++++++++++++ .opencode/README.md | 12 +++++++ .../skills/git-cross-maintainer/SKILL.md | 13 ++++++++ .../git-cross-regression-tester/SKILL.md | 12 +++++++ .../skills/git-cross-safety-reviewer/SKILL.md | 16 ++++++++++ .../skills/git-cross-spec-writer/SKILL.md | 13 ++++++++ 13 files changed, 228 insertions(+) create mode 100644 .agents/README.md create mode 100644 .agents/feature-spec-writer.md create mode 100644 .agents/git-cross-maintainer.md create mode 100644 .agents/regression-tester.md create mode 100644 .agents/safety-reviewer.md create mode 100644 .claude/README.md create mode 100644 .claude/commands.md create mode 100644 .claude/skills.md create mode 100644 .opencode/README.md create mode 100644 .opencode/skills/git-cross-maintainer/SKILL.md create mode 100644 .opencode/skills/git-cross-regression-tester/SKILL.md create mode 100644 .opencode/skills/git-cross-safety-reviewer/SKILL.md create mode 100644 .opencode/skills/git-cross-spec-writer/SKILL.md diff --git a/.agents/README.md b/.agents/README.md new file mode 100644 index 000000000..43f79a940 --- /dev/null +++ b/.agents/README.md @@ -0,0 +1,6 @@ +# Agents + +These files are shared repo-specific agent skills. + +- `git-cross-maintainer.md` explains how to change this repo safely. +- `feature-spec-writer.md` explains how to write specs in this repo. diff --git a/.agents/feature-spec-writer.md b/.agents/feature-spec-writer.md new file mode 100644 index 000000000..b20a32e3f --- /dev/null +++ b/.agents/feature-spec-writer.md @@ -0,0 +1,29 @@ +# Feature Spec Writer Skill + +## Goal + +Write repo-specific specs that are precise enough to implement across all three `git-cross` implementations. + +## Required Sections + +- User problem. +- Scope and non-goals. +- Functional requirements. +- Safety requirements. +- CLI and UX expectations. +- Test requirements. +- Migration or compatibility notes. + +## Repo-Specific Expectations + +- State whether the feature affects Just, Go, Rust, or all three. +- Name the commands affected. +- Describe how `Crossfile` and `.cross/metadata.json` must evolve. +- Call out any root-path or delete-path risks. +- Include explicit regression-test expectations. + +## Anti-Patterns + +- Do not describe only the happy path. +- Do not leave `.crossignore` or root deletion semantics vague. +- Do not assume current implementation behavior is already safe. diff --git a/.agents/git-cross-maintainer.md b/.agents/git-cross-maintainer.md new file mode 100644 index 000000000..bb234b827 --- /dev/null +++ b/.agents/git-cross-maintainer.md @@ -0,0 +1,31 @@ +# git-cross Maintainer Skill + +## Mission + +Make safe, parity-preserving changes to `git-cross`. + +## Rules + +- Read `AGENTS.md` first. +- Treat `Justfile.cross` as the behavior reference. +- Update Go and Rust when behavior is shared. +- Avoid silent error swallowing. +- Be careful with any `rsync --delete` call. +- Treat `local_path == "."` as a destructive-risk case. +- Never assume repo-root patching is safe under the current implementation. + +## Default Work Pattern + +1. Inspect current behavior in Just, Go, and Rust. +2. Check the relevant tests. +3. Make the smallest correct shared change. +4. Add or update tests. +5. Verify focused tests, then broader regression coverage. + +## High-Risk Areas + +- Root path resolution. +- Sparse checkout setup. +- Metadata save/load behavior. +- Remove and prune cleanup logic. +- Sync and push flows that may leak or delete files. diff --git a/.agents/regression-tester.md b/.agents/regression-tester.md new file mode 100644 index 000000000..6d93bf1fd --- /dev/null +++ b/.agents/regression-tester.md @@ -0,0 +1,26 @@ +# Regression Tester Skill + +## Mission + +Define the smallest sufficient regression coverage for `git-cross` behavior changes. + +## Rules + +- Map the changed command to the existing `test/NNN_*.sh` files first. +- Add focused regression coverage before broadening the suite. +- Treat path handling and sync behavior as high-risk. +- Require parity coverage when behavior changes across implementations. + +## Default Verification Order + +1. Relevant focused test. +2. `bash test/003_diff.sh` when path resolution or context behavior changed. +3. `bash test/004_sync.sh` when sync/push/delete behavior changed. +4. `bash test/run-all.sh` for larger feature work. + +## Required Review Output + +- What changed. +- Which tests already cover it. +- Which new tests are required. +- What remains untested. diff --git a/.agents/safety-reviewer.md b/.agents/safety-reviewer.md new file mode 100644 index 000000000..d4640b297 --- /dev/null +++ b/.agents/safety-reviewer.md @@ -0,0 +1,28 @@ +# Safety Reviewer Skill + +## Mission + +Review `git-cross` changes for data-loss risk, secret leakage risk, and cross-implementation inconsistency. + +## Focus Areas + +- `patch`, `sync`, `push`, `remove`, `prune` +- root-path handling +- metadata save/load behavior +- `Crossfile` replay behavior +- any `rsync --delete` usage +- ignore semantics such as `.crossignore` + +## Review Questions + +1. Can this delete files the user did not intend to delete? +2. Can this copy local-only files into the upstream worktree? +3. Does this behave the same in Just, Go, and Rust? +4. Does failure leave metadata or worktrees in a broken state? +5. Are there explicit tests for the dangerous path? + +## Output Style + +- Findings first. +- Include file references. +- Prefer concrete safety failures and missing tests over style comments. diff --git a/.claude/README.md b/.claude/README.md new file mode 100644 index 000000000..34b1244bb --- /dev/null +++ b/.claude/README.md @@ -0,0 +1,6 @@ +# Claude Repo Notes + +This folder is for Claude-facing repo guidance. + +- Use `.agents/` for durable shared skills. +- Use the files here for Claude-specific quick entrypoints and task framing. diff --git a/.claude/commands.md b/.claude/commands.md new file mode 100644 index 000000000..d32f20c0f --- /dev/null +++ b/.claude/commands.md @@ -0,0 +1,15 @@ +# Claude Commands + +## Start Here + +- Read `AGENTS.md`. +- Read `context/repo-resources.md`. +- Read `context/root-patching-review.md` when working on overlay or root patching. +- Read `.agents/git-cross-maintainer.md` before editing code. + +## When Writing Specs + +- Read `.agents/feature-spec-writer.md`. +- Put feature requirements in `specs//spec.md`. +- Put implementation constraints in `specs//plan.md`. +- Put execution work in `specs//tasks.md`. diff --git a/.claude/skills.md b/.claude/skills.md new file mode 100644 index 000000000..d85bd8dd7 --- /dev/null +++ b/.claude/skills.md @@ -0,0 +1,21 @@ +# Claude Skill Guidance + +## Required Repo Skills + +Claude should rely on these repo roles: + +- `.agents/git-cross-maintainer.md` +- `.agents/feature-spec-writer.md` +- `.agents/safety-reviewer.md` +- `.agents/regression-tester.md` + +## Suggested Usage + +- Use `git-cross-maintainer` for code changes. +- Use `feature-spec-writer` before large feature work. +- Use `safety-reviewer` before landing risky path, sync, push, or remove changes. +- Use `regression-tester` before considering behavior work complete. + +## Why This Set + +This repo's main risk is not syntax complexity. It is behavior safety across three implementations. These four roles cover the core lifecycle without adding too much process. diff --git a/.opencode/README.md b/.opencode/README.md new file mode 100644 index 000000000..f3a295b6c --- /dev/null +++ b/.opencode/README.md @@ -0,0 +1,12 @@ +# OpenCode Repo Skills + +This repo keeps canonical shared role guidance in `.agents/` and OpenCode-native skill files in `.opencode/skills/`. + +Required skills: + +- `git-cross-maintainer` +- `git-cross-spec-writer` +- `git-cross-safety-reviewer` +- `git-cross-regression-tester` + +After editing OpenCode skills or config, restart OpenCode so it reloads them. diff --git a/.opencode/skills/git-cross-maintainer/SKILL.md b/.opencode/skills/git-cross-maintainer/SKILL.md new file mode 100644 index 000000000..56e7a932b --- /dev/null +++ b/.opencode/skills/git-cross-maintainer/SKILL.md @@ -0,0 +1,13 @@ +--- +name: git-cross-maintainer +description: Use when changing `git-cross` behavior in `Justfile.cross`, `src-go/main.go`, `src-rust/src/main.rs`, or related tests. Focus on parity, path safety, and minimal correct changes. +--- + +# git-cross Maintainer + +- Read `AGENTS.md` first. +- Treat `Justfile.cross` as the behavior reference. +- Update Go and Rust when the behavior is shared. +- Be careful with `rsync --delete` and any root-path handling. +- Prefer the smallest correct shared change. +- Add or update regression tests before considering behavior work complete. diff --git a/.opencode/skills/git-cross-regression-tester/SKILL.md b/.opencode/skills/git-cross-regression-tester/SKILL.md new file mode 100644 index 000000000..14829fc0a --- /dev/null +++ b/.opencode/skills/git-cross-regression-tester/SKILL.md @@ -0,0 +1,12 @@ +--- +name: git-cross-regression-tester +description: Use when a `git-cross` behavior change needs test planning or verification, especially for sync, diff, status, patch, push, path resolution, and cross-implementation parity. +--- + +# git-cross Regression Tester + +- Identify the most relevant focused test first. +- Require `test/003_diff.sh` for path or context changes. +- Require `test/004_sync.sh` for sync, push, delete, or ignore-model changes. +- Require broader parity coverage for shared behavior changes. +- Call out what remains untested. diff --git a/.opencode/skills/git-cross-safety-reviewer/SKILL.md b/.opencode/skills/git-cross-safety-reviewer/SKILL.md new file mode 100644 index 000000000..76d92b289 --- /dev/null +++ b/.opencode/skills/git-cross-safety-reviewer/SKILL.md @@ -0,0 +1,16 @@ +--- +name: git-cross-safety-reviewer +description: Use when reviewing risky `git-cross` changes involving `patch`, `sync`, `push`, `remove`, `prune`, root paths, `rsync --delete`, metadata, or `.crossignore` semantics. +--- + +# git-cross Safety Reviewer + +Review for: + +- data loss risk +- secret leakage risk +- root-path deletion risk +- metadata inconsistency +- parity gaps across Just, Go, and Rust + +Report findings first with file references and missing-test notes. diff --git a/.opencode/skills/git-cross-spec-writer/SKILL.md b/.opencode/skills/git-cross-spec-writer/SKILL.md new file mode 100644 index 000000000..a09c6e4bf --- /dev/null +++ b/.opencode/skills/git-cross-spec-writer/SKILL.md @@ -0,0 +1,13 @@ +--- +name: git-cross-spec-writer +description: Use when writing or revising feature specs for `git-cross`, especially for new commands, new modes, UX changes, root patching, or `.crossignore` behavior. +--- + +# git-cross Spec Writer + +- Capture the user problem before proposing syntax. +- Separate current behavior from proposed behavior. +- Define safety rules explicitly. +- State which commands change. +- State which implementations change. +- Define test requirements and destructive-risk scenarios. From 8f2464979dd0a191fa0030b7bf98a12fb173c2d9 Mon Sep 17 00:00:00 2001 From: Test User Date: Wed, 27 May 2026 19:22:50 +0200 Subject: [PATCH 3/3] improve --- .agents/README.md | 14 +- .claude/README.md | 3 + .github/workflows/ci.yml | 3 +- .opencode/README.md | 6 + AGENTS.md | 2 +- CHANGELOG.md | 14 + Justfile.cross | 61 ++-- README.md | 339 +++++++++++------- docs/overview.md | 190 ++++++++++ docs/tutorials/local-overlays-and-upstream.md | 151 ++++++++ .../migrating-private-fork-to-git-cross.md | 236 ++++++++++++ .../whole-upstream-into-local-dir.md | 144 ++++++++ sbx-kits/README.md | 97 +++++ sbx-kits/claude/files/home/.claude/README.md | 12 + sbx-kits/claude/spec.yaml | 5 + sbx-kits/opencode/spec.yaml | 5 + specs/002-root-overlay-patching/plan.md | 1 + specs/002-root-overlay-patching/research.md | 17 + specs/002-root-overlay-patching/spec.md | 1 + specs/002-root-overlay-patching/tasks.md | 44 +++ src-go/main.go | 84 ++++- src-rust/Cargo.lock | 75 +++- src-rust/src/main.rs | 108 ++++-- test/007_status.sh | 9 + test/008_rust_cli.sh | 37 ++ test/009_go_cli.sh | 37 ++ test/014_remove.sh | 21 ++ test/015_prune.sh | 29 ++ test/016_diff_context.sh | 12 + 29 files changed, 1551 insertions(+), 206 deletions(-) create mode 100644 docs/overview.md create mode 100644 docs/tutorials/local-overlays-and-upstream.md create mode 100644 docs/tutorials/migrating-private-fork-to-git-cross.md create mode 100644 docs/tutorials/whole-upstream-into-local-dir.md create mode 100644 sbx-kits/README.md create mode 100644 sbx-kits/claude/files/home/.claude/README.md create mode 100644 sbx-kits/claude/spec.yaml create mode 100644 sbx-kits/opencode/spec.yaml create mode 100644 specs/002-root-overlay-patching/plan.md create mode 100644 specs/002-root-overlay-patching/research.md create mode 100644 specs/002-root-overlay-patching/spec.md create mode 100644 specs/002-root-overlay-patching/tasks.md diff --git a/.agents/README.md b/.agents/README.md index 43f79a940..93d5853c4 100644 --- a/.agents/README.md +++ b/.agents/README.md @@ -2,5 +2,15 @@ These files are shared repo-specific agent skills. -- `git-cross-maintainer.md` explains how to change this repo safely. -- `feature-spec-writer.md` explains how to write specs in this repo. +Current shared roles: + +- `git-cross-maintainer.md` for safe code changes across Just, Go, and Rust +- `feature-spec-writer.md` for specs and behavior design work +- `safety-reviewer.md` for risky sync, push, patch, remove, and prune changes +- `regression-tester.md` for focused and full regression planning + +User-facing docs that mention these roles live in: + +- `README.md` +- `docs/overview.md` +- `sbx-kits/README.md` diff --git a/.claude/README.md b/.claude/README.md index 34b1244bb..2285a7149 100644 --- a/.claude/README.md +++ b/.claude/README.md @@ -4,3 +4,6 @@ This folder is for Claude-facing repo guidance. - Use `.agents/` for durable shared skills. - Use the files here for Claude-specific quick entrypoints and task framing. +- User-facing docs for the repo workflow live in `README.md` and `docs/`. +- Repo-local sandbox starter kits live in `sbx-kits/`. +- The Claude sandbox starter for this repo lives at `sbx-kits/claude`. diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 8e2750629..ef398e871 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -17,7 +17,7 @@ jobs: - name: Install dependencies run: | sudo apt-get update - sudo apt-get install -y fish rsync jq + sudo apt-get install -y fish rsync jq ripgrep # Install yq sudo wget --retry-connrefused --waitretry=1 --read-timeout=20 --timeout=15 -t 5 https://github.com/mikefarah/yq/releases/latest/download/yq_linux_amd64 -O /usr/bin/yq sudo chmod +x /usr/bin/yq @@ -32,6 +32,7 @@ jobs: git --version rsync --version jq --version + rg --version yq --version - name: Run dependency check diff --git a/.opencode/README.md b/.opencode/README.md index f3a295b6c..4c952c6c8 100644 --- a/.opencode/README.md +++ b/.opencode/README.md @@ -10,3 +10,9 @@ Required skills: - `git-cross-regression-tester` After editing OpenCode skills or config, restart OpenCode so it reloads them. + +For sandbox usage, prefer the repo-local starter kit: + +- `sbx-kits/opencode` + +That keeps the sandbox setup versioned with the repository instead of relying on a user-home path. diff --git a/AGENTS.md b/AGENTS.md index d69e93d94..b6e94b1be 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -196,7 +196,7 @@ Tests 001-007 form a chain for Shell/Just (001 is sourced by 002, etc.). Tests 0 - **Sparse checkout**: `git sparse-checkout init --no-cone` + `git sparse-checkout set /` + `git read-tree -mu HEAD`. The trailing `/` on the pattern is required for reliable directory matching in `--no-cone` mode on newer Git versions (2.43+). `read-tree -mu HEAD` explicitly materializes the worktree (bare `git checkout` can no-op after `--no-checkout`). - **Rsync**: `rsync -av --delete --exclude .git` for worktree-to-local sync. `--delete` removes files locally that were deleted upstream. - **Crossfile format**: Lines like `cross use ` or `cross patch :: `. Parsed as bash during `replay`. -- **Metadata format**: JSON at `.git/cross/metadata.json`. Schema: `{"patches": [{"id", "remote", "remote_path", "local_path", "worktree", "branch"}]}`. +- **.crossignore**: Located in the root of each local_path, uses gitignore-style patterns to exclude files from status and diff comparisons. Implemented by filtering directory trees using git add and git clean to remove ignored files before comparison. ## Refactoring Priorities diff --git a/CHANGELOG.md b/CHANGELOG.md index 020f3a05c..e5eaaca97 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,7 +7,21 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Added +- **User documentation refresh** - Added a clearer top-level README, a user-facing architecture overview, and a tutorial for local overlays, publishing to your own origin, and sending changes back upstream. +- **Repo-local `sbx` starter kit** - Added `sbx-kits/opencode` plus `sbx-kits/README.md` so users can try `git-cross` in a versioned sandbox setup instead of relying on a home-directory kit path. +- **AI skill documentation pointers** - Expanded the repo docs to point users and agent tools at `.agents/`, `.claude/`, and `.opencode/skills/`. +- **Whole-upstream tutorial** - Added a second tutorial showing how to vendor an entire upstream repository into a local directory with `remote:.` or `remote:/`. +- **Claude sandbox starter** - Added `sbx-kits/claude` so repo-local sandbox examples now cover both OpenCode and Claude. +- **Private fork migration tutorial** - Added a guarded tutorial for migrating an existing private fork or derivative repo to a repo-root `git-cross` patch with explicit backup and override review steps. + +### Changed +- **`.crossignore` entry syntax** - Override review behavior now uses plain non-comment `.crossignore` lines such as `.env` or `config/private` instead of `!override ` markers. Wildcard pattern matching is still not supported. +- **README workflow guidance** - Documented the current `.crossignore` behavior as a review-oriented workflow for local overlay files layered on top of upstream-managed content. + ### Fixed +- **Rust override diff build failure** - Renamed a Rust loop variable that used the reserved keyword `override`, restoring Rust CLI builds and regression execution on current toolchains. +- **Just override warning output** - Removed a shell-breaking semicolon from the override review warning so `just cross diff` prints the manual review commands correctly. - **P0: Sparse checkout broken on newer Git versions** - `git sparse-checkout set ` 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. - **Whole-repo patching (`:/` and `:.`)** - `cross patch remote:/` and `cross patch remote:.` now correctly patch the entire upstream repo. Previously `/` was rejected as invalid, and `.` produced an empty worktree because sparse-checkout pattern `./` matched nothing. Fix: `/` is normalized to `.`, and `.` skips sparse-checkout entirely (full checkout). diff --git a/Justfile.cross b/Justfile.cross index c3170714b..54de7136d 100644 --- a/Justfile.cross +++ b/Justfile.cross @@ -87,14 +87,14 @@ test-shell *ARGS="bash": check-deps: #!/usr/bin/env fish set -l missing - for cmd in fish rsync git jq + for cmd in fish rsync git jq rg if not command -v $cmd > /dev/null set missing $missing $cmd end end if test (count $missing) -gt 0 just cross _log error "Missing: $missing" - just cross _log info "Install with: brew install $missing" + just cross _log info "Install the missing binaries with your package manager." exit 1 end @@ -139,6 +139,19 @@ _resolve_context2 path="": check-initialized update_crossfile +cmd: grep -qF "{{cmd}}" "{{CROSSFILE}}" 2>/dev/null || echo "{{cmd}}" >> "{{CROSSFILE}}"; exit 0 +[no-cd] +_crossignore_overrides local_dir: + #!/usr/bin/env fish + set -l file "{{local_dir}}/.crossignore" + if not test -f "$file" + exit 0 + end + while read -l line + set -l trimmed (string trim -- "$line") + if test -n "$trimmed"; and not string match -q '#*' -- "$trimmed" + printf '%s\n' "$trimmed" + end + end < "$file" # Internal: Log message with color _log level +message: @@ -241,9 +254,11 @@ remove path: check-deps jq --arg lp "$l_path" '.patches |= map(select(.local_path != $lp))' {{METADATA}} > "$tmp_meta" mv "$tmp_meta" {{METADATA}} - # 4. Remove local directory - just cross _log info "Deleting local directory $l_path..." - rm -rf "$l_path" + # 4. Remove local directory (with root guard) + if test "$l_path" != "." -a -n "$l_path" + just cross _log info "Deleting local directory $l_path..." + rm -rf "$l_path" + end just cross _log success "Patch removed successfully." popd @@ -357,20 +372,15 @@ patch remote_spec local_path="": check-deps end # update vars — treat "/" as "." (whole repo root) - set r_path "$remote_path" - set r_path (string trim -l -c '/' "$r_path") - set r_path (string trim -r -c '/' "$r_path") - if test -z "$r_path" - set r_path "." - end + set r_path (string trim -c '/' -- "$remote_path") + test -n "$r_path"; or set r_path "." + set l_path "{{local_path}}" - if test -z "$l_path" - if test "$r_path" = "." - just cross _log error "Error: local_path is required when patching whole repo (path is '.')" - exit 1 - end - set l_path "$r_path" + if test -z "$l_path" -a "$r_path" = "." + just cross _log error "Error: local_path is required when patching whole repo (path is '.')" + exit 1 end + test -n "$l_path"; or set l_path "$r_path" pushd "{{REPO_DIR}}" @@ -662,7 +672,14 @@ diff path="": check-initialized pushd "{{REPO_DIR}}" if test -d $worktree - git diff --no-index $worktree/$remote_path $local_path || true + set overrides (just cross _crossignore_overrides "{{REPO_DIR}}/$local_path") + if test -z "$overrides[1]" + git diff --no-index $worktree/$remote_path $local_path || true + else + for override in $overrides + printf 'git diff --no-index "%s" "%s"\n' "{{REPO_DIR}}/$worktree/$remote_path/$override" "{{REPO_DIR}}/$local_path/$override" + end + end else just cross _log error "Error: Worktree not found $worktree" exit 1 @@ -899,6 +916,7 @@ cd path="": [no-cd] status: check-deps #!/usr/bin/env fish + cd "{{REPO_DIR}}" if not test -f Crossfile just cross _log warn "No patches found." exit 0 @@ -915,8 +933,11 @@ status: check-deps if test -d $wt # Check diffs - if not git diff --no-index --quiet $wt/$rpath $local_path 2>/dev/null - set diff_stat "Modified" + set overrides (just cross _crossignore_overrides "{{REPO_DIR}}/$local_path") + if test (count $overrides) -gt 0 + set diff_stat "Override" + else if not git diff --no-index --quiet $wt/$rpath $local_path 2>/dev/null + set diff_stat "Modified" end # Check upstream divergence diff --git a/README.md b/README.md index de4f10da1..0796f4d4a 100644 --- a/README.md +++ b/README.md @@ -1,223 +1,300 @@ -# git-cross 🧬 +# git-cross [![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.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`. +`git-cross` is a small tool for pulling part of one Git repository into another with `git worktree` plus `rsync`. -## Why git-cross? +It is for the cases where you want real files in your tree, not a gitlink, and still want a clean path back upstream. -| Feature | git-cross | Submodules | git-subrepo | -|---------|-----------|------------|-------------| -| **Physical files** | ✅ Yes | ❌ Gitlinks only | ✅ Yes | -| **Easy to modify** | ✅ Direct edits | ⚠️ Complex | ✅ Direct edits | -| **Partial checkout** | ✅ Subdirectories | ❌ Entire repo | ❌ Entire repo | -| **Upstream sync** | ✅ Bidirectional | ⚠️ Complex | ⚠️ Merge commits | -| **Commit visibility** | ✅ In main repo | ❌ Separate | ✅ In main repo | -| **Reproducibility** | ✅ Crossfile | ⚠️ .gitmodules | ⚠️ Manual | +## Why Use It -## What it is not +`git-cross` is useful when you want to: -Git-cross is not a replacement for `git-subrepo` or `git-submodule`. -- It provides an alternative approach and simplifies otherwise manual and complex git workflow behind into intuitive commands. +- vendor only one subdirectory from another repository +- keep vendored code as normal files in your own repository +- pull upstream updates without converting your repo into a submodule setup +- push changes back to a fork or upstream project without manual worktree plumbing +- let AI tools work on a mounted subfolder instead of your full repository -Git-cross does not directly link external repositories to your main repository. -- It provides separate worktrees for each upstream patch, and help with sync to local repository. +Compared with submodules or subrepo-style flows, the tradeoff is simple: `git-cross` keeps files physical and visible in your repo, and tracks the upstream relationship separately through hidden worktrees and `Crossfile`. -## Implementation status +## Common Use Cases -The project is still in early days and Work In Progress. Just/Golang versions are tested by the author on best-effort basis. Most of the commands and structure of "Crossfile" is already freezed. +- Pull `docs/` from an upstream project into `vendor/docs` +- Keep a shared app template in `vendor/template` and customize it locally +- Work on an upstream library inside `vendor/lib` with normal editor tooling +- Let an AI sandbox mount only `vendor/lib` instead of the whole repo +- Publish your local combined result to your own `origin`, while still being able to send a clean PR or MR upstream later -The project provides three implementations, with **Go being the primary native version for production use.** +## Status -1. **Go Implementation:** The most robust and feature-complete version. Recommended for general use. -2. **Justfile/Fish:** The original functional version, great for integration-first workflows. -3. **Rust Implementation:** Currently **EXPERIMENTAL / WIP**. High-performance alternative being refactored to use native libraries. +The repository ships three implementations: + +1. Go: primary production implementation +2. Just/Fish: readable reference implementation +3. Rust: experimental parity implementation + +For normal usage, prefer the Go binary. ## Installation -### Method 1: Go CLI (Recommended) -The Go version is the primary implementation. Use the shortcut or follow manual steps. +### Go CLI -**Automation:** ```bash -just install # Installs Go CLI (default) -just install shell # Installs Just/Shell version -just install rust # Installs Rust implementation (WIP) +just install ``` -**Manual steps:** -1. Download a pre-built binary from [GitHub Releases](https://github.com/epcim/git-cross/releases). -2. Or build locally: - ```bash - cd src-go && go build -o git-cross main.go - # Setup git alias 'cross' - git config --global alias.cross "!$(pwd)/git-cross" - ``` +Manual build: -### Method 2: Just (Vendoring) -The original functional version. Ideal for projects already using `just`. +```bash +cd src-go +go build -o git-cross-go main.go +git config --global alias.cross "!$(pwd)/git-cross-go" +``` -**Manual steps (for vendoring in your project):** -1. Clone the repo: `git clone https://github.com/epcim/git-cross.git vendor/git-cross` -2. Install alias: `git config --global alias.cross-just "!just --justfile $(pwd)/vendor/git-cross/Justfile cross"` -3. Import in your `Justfile`: `import? 'vendor/git-cross/Justfile'` +### Shell Reference -### Method 3: Rust CLI (Experimental / WIP) -High-performance alternative for contributors or library interop testing. +```bash +just install shell +``` + +### Rust CLI -**Manual steps:** ```bash -cd src-rust -cargo install --path . -# Setup git alias 'cross-rust' -git config --global alias.cross-rust "!git-cross-rust" +just install rust ``` ## Quick Start ```bash -# Setup upstream +# 1. Register upstream git cross use demo https://github.com/example/demo.git -# Vendor a subdirectory +# 2. Materialize one directory locally git cross patch demo:docs vendor/docs -# Pull updates -git cross sync - -# Check status +# 3. Inspect local state git cross status +git cross diff vendor/docs + +# 4. Pull new upstream changes later +git cross sync vendor/docs +``` + +## How It Works + +`git-cross` keeps four things in sync: + +1. Your main repository +2. A hidden worktree for each upstream patch under `.cross/worktrees/` +3. A local physical directory such as `vendor/docs` +4. A `Crossfile` that records how to rebuild the setup + +In practice: + +- your editor sees normal files +- AI tools can work on normal files +- upstream history stays in the hidden worktree +- `git cross replay` can reconstruct the setup later + +## Local Custom Files On Top Of Upstream + +One common pattern is to keep local-only files next to upstream-managed files. + +Example: + +```bash +git cross use app https://github.com/example/app.git +git cross patch app:src vendor/app + +cat > vendor/app/.crossignore <<'EOF' +.env +compose.override.yaml +EOF +``` + +What this does today: + +- `git cross status` shows `Override` for that patch +- `git cross diff vendor/app` prints manual `git diff --no-index ...` commands for the override paths +- your local files stay in place for normal local work + +This is intentionally conservative. `.crossignore` entries are review signals, not a substitute for review before `sync` or `push`. + +If you keep secrets or machine-local files in a patched directory, review them explicitly before sending changes upstream. + +## Publish To Your Own Repo And Upstream + +There are two separate publish flows. + +### 1. Publish the combined result to your own `origin` + +This is just normal Git in your main repository: + +```bash +git add vendor/app +git commit -m "Update vendored app and local overlays" +git push origin main +``` + +Your own repository stores the final combined result, including local overlay files if you choose to commit them there. + +### 2. Send changes back to the upstream project + +When you want to contribute upstream-managed changes back: + +```bash +git cross diff vendor/app +git cross push vendor/app --message "Fix upstream bug" ``` -## Core Commands +Recommended pattern: + +1. Track a writable fork with `git cross use`. +2. Patch from that fork into your local tree. +3. Keep local-only overlay files out of the upstream contribution. +4. Run `git cross push` for the vendored path. +5. Open a PR or MR from your fork branch to the original upstream project. + +This lets your main repo keep its local opinionated result while the upstream contribution stays focused. + +## Tutorials + +- [Overview](docs/overview.md) +- [Tutorial: Local Overlays And Upstream Contribution](docs/tutorials/local-overlays-and-upstream.md) +- [Tutorial: Whole Upstream Into A Local Directory](docs/tutorials/whole-upstream-into-local-dir.md) +- [Tutorial: Migrate A Private Fork To git-cross](docs/tutorials/migrating-private-fork-to-git-cross.md) +- [Sandbox Kits](sbx-kits/README.md) + +## Commands + +### `use` -#### `use` - Add Upstream ```bash git cross use ``` -Adds a remote repository and autodetects the default branch. -#### `patch` - Vendor Directory +Register an upstream remote alias and detect its default branch. + +### `patch` + ```bash git cross patch : [local_dest] ``` -Creates a sparse-checkout worktree and syncs files locally. -#### `sync` - Pull Updates +Create or reuse a hidden worktree, sparse-check out the upstream path, and sync it into your local directory. + +### `sync` + ```bash git cross sync [path] ``` -Fetches latest changes from upstream and updates local vendored files. -#### `status` - Check Health +Pull upstream changes into the local patched directory. + +### `diff` + ```bash -git cross status +git cross diff [path] ``` -Shows if files are modified locally, behind upstream, or have conflicts. -#### `list` - Show Patches +Compare local files with the hidden worktree copy. + +### `status` + ```bash -git cross list +git cross status ``` -Displays all configured patches in a table. -#### `push` - Contribute Back +Show local diff state, upstream divergence, and conflicts. + +### `push` + ```bash git cross push [path] [--force] [--message "msg"] ``` -Syncs local changes back to the worktree, commits, and pushes to upstream. -#### `replay` - Restore State +Sync local changes back into the worktree, commit them there, and push to the upstream remote. + +### `replay` + ```bash git cross replay ``` -Re-executes all commands in `Crossfile` to recreate the vendored environment. -## Advanced Features +Rebuild the full setup from `Crossfile`. + +### `remove` -### Custom Hooks -You can use the `exec` command in your `Crossfile` for post-patching tasks: ```bash -# Crossfile -cross patch demo:src vendor/src -cross exec "npm install && npm run build" +git cross remove ``` -> **Note**: While `cross` is the standard prefix for `Crossfile` entries (ensuring portability), you can also use `git cross` or `just cross` if you prefer specific implementation behavior. +Remove one patch and clean up local state. -### Just Integration -If using `just`, you can override targets to add pre/post hooks: -```just -@cross *ARGS: - echo "Before..." - just --justfile vendor/git-cross/Justfile.cross {{ARGS}} - echo "After..." +### `prune` + +```bash +git cross prune [remote] ``` -## How It Works -1. **Worktrees**: Maintains hidden worktrees in `.git/cross/worktrees/`. -2. **Sparse Checkout**: Only checks out the specific directories you need. -3. **Rsync**: Efficiently syncs changes between worktree and your source tree. -4. **Crossfile**: A plain-text record of all active patches for easy sharing. +Remove unused remotes, stale worktrees, or all patches for one remote. -## AI-Assisted Coding and Sandbox Workflows +## Safety Notes -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. +- `remove` and `prune` now guard against deleting repo root contents when a patch targets `.` +- whole-upstream patching with `remote:.` and `remote:/` works for vendoring into local directories +- override markers in `.crossignore` currently affect review surfaces: `status` and `diff` +- if you are experimenting with root-target overlay workflows, treat them as advanced usage and verify results carefully -**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. +## AI Tooling And Skills -### Docker Sandbox (`sbx`) Integration +This repo already includes agent guidance for AI coding tools: -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/`: +- shared repo roles in `.agents/` +- Claude-facing entrypoints in `.claude/` +- OpenCode-native skills in `.opencode/skills/` -```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 +Current shared roles: -# 2. Create a sandbox scoped to the vendor subfolder -sbx create --mount vendor/lib # AI tool sees only vendor/lib/ +- maintainer +- feature spec writer +- safety reviewer +- regression tester -# 3. AI modifies files in the sandbox (vendor/lib/) -# 4. Push changes back upstream from the host -git cross push vendor/lib -``` +If you want an AI agent to work only on a vendored subtree, mount just that subtree in a sandbox and keep the main `.git` state on the host. + +## Docker `sbx` Starter -**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. +This repo includes repo-local starter kits under `sbx-kits/`. -### Rules for AI-Assisted Development +Examples: -When using AI tools with git-cross managed subfolders: +```bash +sbx run opencode --kit ./sbx-kits/opencode +sbx run claude --kit ./sbx-kits/claude +``` -1. **Scope AI context to subfolders.** Share `vendor//` 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. +Why use a kit: -## Architecture +- safer trial path for people who do not want to install a new tool globally first +- reproducible sandbox setup checked into the repository +- easy place to add sandbox-local agent config later -### Technical Implementation Analysis +See [sbx-kits/README.md](sbx-kits/README.md) for the structure, starter kits, and intended workflow. -`git-cross` provides three distinct implementation layers, ensuring the tool is available as a shell-based coordinator or a production-grade native CLI. +## Architecture Summary -| Feature | Go (Primary) | Pure Justfile | Rust (Exp.) | winner | -| :--- | :---: | :---: | :---: | :---: | -| **Philosophy** | Porcelain Wrapper | Shell Coordination | Library-First | **Go** (for balance) | -| **CLI Ergonomics** | Cobra (Standard) | Task-based | Clap (Elegant) | **Rust** | -| **Git Interop** | Binary Wrapper | Direct CLI calls | Native Bindings | **Shell** (for transparency) | -| **Distribution** | Static (Zip/One) | Tool-dependent | Compiled (C-link) | **Go** | -| **Speed to Fix** | Fast | Instant | Medium | **Shell** | +High level: -### Verdict: The Multi-Layer Strategy -* **Go (Primary):** The designated production version. It offers the best balance of distribution ease (zero-dependency binaries) and reliable Git orchestration. -* **Justfile:** The original source of truth. It remains the fastest way to integrate `git-cross` into existing CI/CD pipelines that already use `just`. -* **Rust (Experimental):** A high-performance alternative exploring native library integration (`libgit2`). Best for users who require memory safety and a premium CLI experience. +- Just/Fish is the readable behavior reference +- Go is the main production CLI +- Rust is parity-oriented and still experimental + +Detailed architecture notes live in [docs/overview.md](docs/overview.md). ## License + MIT diff --git a/docs/overview.md b/docs/overview.md new file mode 100644 index 000000000..0c78290fd --- /dev/null +++ b/docs/overview.md @@ -0,0 +1,190 @@ +# Overview + +## What `git-cross` Actually Does + +`git-cross` lets you borrow part of another repository without turning that repository into a submodule inside yours. + +It does that by keeping: + +- a hidden worktree with real upstream Git state +- a local copied directory with normal files in your repo +- a `Crossfile` that records how the setup was created + +The local directory is what you edit. The worktree is what `git-cross` uses to sync, diff, and push. + +## Mental Model + +Each patch has four coordinates: + +- remote alias, for example `demo` +- upstream branch, for example `main` +- upstream path, for example `src/lib` +- local path, for example `vendor/lib` + +You can think of one patch as: + +```text +upstream repo:path <-> hidden worktree:path <-> local directory +``` + +## Main Files And Directories + +### `Crossfile` + +Human-readable replay log. + +Example: + +```bash +cross use demo https://github.com/example/demo.git +cross patch demo:main:src/lib vendor/lib +``` + +### `.cross/worktrees/` + +Hidden worktrees keyed by remote plus hash. + +They hold upstream Git state and make `sync`, `diff`, and `push` possible without turning your vendored directory into another Git repository. + +### `.cross/metadata.json` + +Internal patch registry. + +It records the relationship between remote path, local path, worktree path, and branch. + +## Command Flow + +### `use` + +Adds a named remote and detects the default branch. + +### `patch` + +Creates or reuses a hidden worktree, sparse-checks out the requested upstream path, and syncs it into the local directory. + +### `sync` + +Updates from upstream into the local directory. + +### `diff` + +Compares local files with the hidden worktree copy. + +### `status` + +Summarizes local modifications, upstream ahead/behind state, and conflict markers. + +### `push` + +Copies local changes back into the hidden worktree, commits there, and pushes to the configured upstream remote. + +### `replay` + +Reconstructs the environment from `Crossfile`. + +### `remove` and `prune` + +Remove patch metadata, clean worktrees, and avoid unsafe deletion of repo-root content. + +## Three Implementations + +### Go + +Primary implementation. + +Use this if you want the main supported binary. + +### Just/Fish + +Reference implementation. + +Use this when you want the clearest behavior or fastest iteration inside the repo. + +### Rust + +Experimental parity implementation. + +Useful for contributors and parity testing, but not the default recommendation for general users yet. + +## Local Overlay Files + +One practical pattern is to keep local-only files next to upstream-managed files. + +Example: + +```text +vendor/app/ + README.md # upstream-managed + src/... # upstream-managed + .env # local-only + compose.override.yaml # local-only + .crossignore +``` + +With: + +```text +.env +compose.override.yaml +``` + +Current shipped behavior: + +- `status` shows `Override` +- `diff` prints manual compare commands for those override paths + +This gives you a visible review surface without pretending that local-only files are safe to push blindly. + +## Safety Model + +`git-cross` is built around a few conservative ideas: + +- keep upstream Git state separate from your main repo +- keep local files editable as plain files +- prefer explicit review over hidden automation around local-only files +- avoid deleting repo-root contents during cleanup commands + +Important current boundary: + +- override markers are currently a review aid in `status` and `diff` +- if a file must never be sent upstream, review it explicitly before `push` + +## Typical Collaboration Pattern + +1. Patch an upstream directory into `vendor/...` +2. Edit files locally as normal +3. Commit the combined result to your own repo +4. Pull upstream changes later with `sync` +5. Push upstream-safe changes back with `git cross push` +6. Open a PR or MR from the pushed branch if needed + +## AI And Sandbox Fit + +`git-cross` works well with AI tools because vendored content is plain files. + +That means you can: + +- mount only `vendor/lib` into a sandbox +- keep `.git` and hidden worktrees on the host +- review AI changes with `git cross diff` +- publish upstream-safe changes with `git cross push` + +See: + +- `sbx-kits/README.md` +- `docs/tutorials/local-overlays-and-upstream.md` + +## Repo-Specific Skills + +This repository ships role guidance for AI tools: + +- `.agents/` for shared role definitions +- `.claude/` for Claude entrypoints +- `.opencode/skills/` for OpenCode-native skills + +The main roles are: + +- maintainer +- feature spec writer +- safety reviewer +- regression tester diff --git a/docs/tutorials/local-overlays-and-upstream.md b/docs/tutorials/local-overlays-and-upstream.md new file mode 100644 index 000000000..632c7263b --- /dev/null +++ b/docs/tutorials/local-overlays-and-upstream.md @@ -0,0 +1,151 @@ +# Tutorial: Local Overlays And Upstream Contribution + +## Goal + +This tutorial shows a practical workflow where you: + +- vendor an upstream directory into your repo +- add local-only files on top of it +- publish the combined result to your own `origin` +- later send a clean change back upstream + +This tutorial uses a local subdirectory such as `vendor/app`, because that is the safest supported pattern today. + +## Scenario + +Assume: + +- your repo is the product repo your team owns +- upstream repo contains a reusable app or library +- you want local custom files next to the upstream-managed files + +Example local layout after patching: + +```text +vendor/app/ + README.md + src/ + .env + compose.override.yaml + .crossignore +``` + +## Step 1: Register Upstream + +```bash +git cross use app https://github.com/example/app.git +``` + +This creates a named upstream alias, here `app`. + +## Step 2: Patch The Upstream Directory Locally + +```bash +git cross patch app:src vendor/app +``` + +Now `vendor/app` contains normal files from the upstream `src` directory. + +## Step 3: Add Local-Only Overlay Files + +Create the local files you need: + +```bash +touch vendor/app/.env +touch vendor/app/compose.override.yaml +``` + +Then mark them as local overrides: + +```bash +cat > vendor/app/.crossignore <<'EOF' +.env +compose.override.yaml +EOF +``` + +## Step 4: Review Local State + +```bash +git cross status +git cross diff vendor/app +``` + +What you should see: + +- `status` reports `Override` for `vendor/app` +- `diff` prints manual compare commands for the override paths + +This is a deliberate review point. + +If a file is local-only, treat it as local-only all the way through your review process. + +## Step 5: Publish The Combined Result To Your Own Repo + +Your own repo is still just normal Git. + +```bash +git add vendor/app +git commit -m "Vendor app and keep local overlays" +git push origin main +``` + +This publishes the combined result to your own team repository. + +That is separate from contributing changes back to the upstream project. + +## Step 6: Pull New Upstream Changes Later + +```bash +git cross sync vendor/app +``` + +Then review again: + +```bash +git cross status +git cross diff vendor/app +``` + +Especially if local-only files live next to upstream-managed files, keep the review loop explicit. + +## Step 7: Prepare A Clean Upstream Contribution + +When you want to send a fix upstream, first separate the changes that belong upstream from the changes that belong only in your repo. + +The cleanest setup is to track a writable fork from the start, then open a PR or MR from that fork to the original project. + +If your current patch points at a read-only upstream remote, switch the tracked remote to your fork before using `git cross push`. + +Before pushing upstream-bound changes: + +1. review `git cross diff vendor/app` +2. make sure local-only overlay files are not part of what you intend to send upstream +3. keep the upstream contribution limited to upstream-managed files + +Then push: + +```bash +git cross push vendor/app --message "Fix parser bug" +``` + +After that, open a PR or MR from your fork branch to the original upstream project. + +## Practical Advice + +- Use your main repo `origin` for your final combined product state. +- Use a fork remote for upstream contribution. +- Keep local secrets and machine config clearly marked in `.crossignore`. +- Treat `.crossignore` entries as review helpers, not as permission to stop thinking about what gets pushed. + +## When To Use A Sandbox + +If you want to try this workflow without giving an agent or tool access to your whole repository, use the repo-local kit: + +```bash +sbx run opencode --kit ./sbx-kits/opencode +``` + +Then mount only the vendored path you want the tool to edit. + +See `sbx-kits/README.md` for the layout. diff --git a/docs/tutorials/migrating-private-fork-to-git-cross.md b/docs/tutorials/migrating-private-fork-to-git-cross.md new file mode 100644 index 000000000..3f8c3d1c9 --- /dev/null +++ b/docs/tutorials/migrating-private-fork-to-git-cross.md @@ -0,0 +1,236 @@ +# Tutorial: Migrate A Private Fork To `git-cross` + +## Goal + +This tutorial shows a cautious migration path for a private repository that started life as a fork or derivative of an upstream project and now contains: + +- private files such as `.env` +- machine-local or company-local overrides +- upstream-derived files mixed together at repo root + +The end state is: + +- the repo root is tracked as a `git-cross` patch +- upstream relationship is managed through a hidden worktree +- local-only files are restored and marked for review with `.crossignore` + +## Important Safety Note + +This is an advanced workflow. + +Current shipped behavior: + +- non-comment entries in `.crossignore` affect review surfaces such as `status` and `diff` +- they do **not** guarantee protection during the initial repo-root materialization step + +So for the first migration patch, do **not** rely on `.crossignore` alone. + +Use: + +1. a backup branch or tag +2. a fresh clone or throwaway migration branch +3. an external copy of all local-only files before running `git cross patch upstream:. .` + +## Scenario + +Assume: + +- current repo root contains upstream-derived application files +- your private repo also contains local-only files such as `.env`, `.env.local`, `docker-compose.override.yml`, or private config directories +- you want the repo to stay at repo root, not move upstream content into `vendor/...` + +## Step 1: Make A Safety Snapshot + +Before changing anything, create a backup branch or tag. + +Example: + +```bash +git checkout -b backup/pre-git-cross-migration +git push origin backup/pre-git-cross-migration +``` + +If you prefer tags: + +```bash +git tag pre-git-cross-migration +git push origin pre-git-cross-migration +``` + +## Step 2: Work In A Fresh Clone Or Throwaway Branch + +The safest migration is done in a fresh clone of your private repo. + +That way, if the first root patch is not what you expected, you can discard the whole working copy. + +## Step 3: Inventory Local-Only Files + +Make a list of files that must stay private or local-only. + +Typical examples: + +- `.env` +- `.env.local` +- `docker-compose.override.yml` +- `config/private/` +- machine-local certificate files + +For current shipped behavior, define them as explicit `.crossignore` entries. + +Current parsing rules are simple: + +- each non-empty, non-comment line is treated as one literal override path +- plain entries such as `.env` or `config/private` are supported +- wildcard forms such as `*.env` or `config/*` are **not** supported today + +Example list: + +```text +.env +.env.local +docker-compose.override.yml +config/private +``` + +Examples that are **not** currently supported as patterns: + +```text +*.env +config/* +``` + +## Step 4: Copy Local-Only Files Out Of The Repo + +Before the first `git-cross` root patch, copy those files outside the repository. + +Example: + +```bash +mkdir -p ../private-overrides-backup +cp .env ../private-overrides-backup/ 2>/dev/null || true +cp .env.local ../private-overrides-backup/ 2>/dev/null || true +cp docker-compose.override.yml ../private-overrides-backup/ 2>/dev/null || true +cp -R config/private ../private-overrides-backup/ 2>/dev/null || true +``` + +If a file is sensitive, verify that your backup location is safe. + +## Step 5: Register The Upstream Remote + +If you want upstream contribution later, the cleanest pattern is to register a writable fork from the start. + +If you only want to mirror the original upstream first, register the original upstream now and switch to a fork later. + +Example: + +```bash +git cross use upstream https://github.com/example/project.git +``` + +## Step 6: Patch Upstream Root Into Repo Root + +Now create the repo-root patch: + +```bash +git cross patch upstream:. . +``` + +Equivalent form: + +```bash +git cross patch upstream:/ . +``` + +This is the key migration step: repo root is now associated with the upstream root through a hidden worktree. + +## Step 7: Restore Local-Only Files And Create `.crossignore` + +Restore the local-only files you copied out earlier. + +Example: + +```bash +cp ../private-overrides-backup/.env . 2>/dev/null || true +cp ../private-overrides-backup/.env.local . 2>/dev/null || true +cp ../private-overrides-backup/docker-compose.override.yml . 2>/dev/null || true +cp -R ../private-overrides-backup/private ./config/ 2>/dev/null || true +``` + +Then write `.crossignore`: + +```bash +cat > .crossignore <<'EOF' +.env +.env.local +docker-compose.override.yml +config/private +EOF +``` + +Use explicit literal entries even when it feels repetitive. The current code treats `.crossignore` here as a small override registry, not as full gitignore-style pattern matching. + +## Step 8: Review The Migrated State + +Run: + +```bash +git cross status +git cross diff . +``` + +What to expect: + +- `status` should show `Override` for the root patch if override markers exist +- `diff .` should print manual `git diff --no-index ...` commands for the override files + +This is the point where you confirm the repo now matches the upstream-managed tree plus your restored private files. + +## Step 9: Commit The New Managed Layout To Your Private Repo + +Once the migrated state looks correct, commit it to your private repository. + +```bash +git add Crossfile .crossignore . +git commit -m "Migrate repo root to git-cross managed upstream" +git push origin main +``` + +## Step 10: Day-2 Workflow After Migration + +After the migration: + +- pull upstream updates with `git cross sync .` +- review root changes with `git cross diff .` +- inspect state with `git cross status` +- publish the combined result to your private `origin` with normal Git + +## Step 11: Contribute Changes Back Upstream + +If you want to send some changes back upstream later: + +1. review `git cross diff .` +2. make sure private files are not part of the upstream contribution +3. prefer using a writable fork as the tracked remote +4. push carefully and verify the resulting branch + +Example: + +```bash +git cross push . --message "Fix upstream behavior" +``` + +Then open a PR or MR from the pushed branch. + +## Practical Recommendation + +If you are not forced to keep the imported project at repo root, vendoring the upstream into a local directory such as `vendor/upstream-app` is still the safer default. + +Use repo-root migration when: + +- the repo is already structured that way +- moving files into a subdirectory would be too disruptive +- you are willing to do a careful one-time migration with backups and review checkpoints + +For the simpler whole-upstream case inside a local directory, see: + +- `docs/tutorials/whole-upstream-into-local-dir.md` diff --git a/docs/tutorials/whole-upstream-into-local-dir.md b/docs/tutorials/whole-upstream-into-local-dir.md new file mode 100644 index 000000000..dff21d5a3 --- /dev/null +++ b/docs/tutorials/whole-upstream-into-local-dir.md @@ -0,0 +1,144 @@ +# Tutorial: Whole Upstream Into A Local Directory + +## Goal + +This tutorial shows how to vendor an entire upstream repository into a local directory such as `vendor/upstream-app`. + +Use this when: + +- you want the whole upstream tree, not just one subdirectory +- you still want normal files in your repository +- you want to pull updates later or send changes back upstream + +This tutorial vendors the whole upstream into a local directory, not into repo root. + +## Scenario + +Assume you want to embed the complete contents of an upstream repository into: + +```text +vendor/upstream-app +``` + +That keeps the workflow simple and keeps cleanup safer than a repo-root experiment. + +## Step 1: Register Upstream + +```bash +git cross use app https://github.com/example/app.git +``` + +## Step 2: Patch The Whole Repository + +You can use either `:.` or `:/`. + +```bash +git cross patch app:. vendor/upstream-app +``` + +Equivalent form: + +```bash +git cross patch app:/ vendor/upstream-app +``` + +After that, `vendor/upstream-app` contains the full upstream checkout as normal files. + +## Step 3: Inspect The Imported Tree + +```bash +git cross status +git cross diff vendor/upstream-app +``` + +At this point the patch should normally be `Clean` and `Synced`. + +## Step 4: Customize The Vendored Tree + +Edit files under `vendor/upstream-app` as needed. + +For example: + +```bash +printf '\nlocal customization\n' >> vendor/upstream-app/README.md +``` + +Review the result: + +```bash +git cross diff vendor/upstream-app +``` + +## Step 5: Keep Local-Only Files Next To It + +If you need machine-local or product-local files alongside the imported tree, create them locally and mark them in `.crossignore`. + +Example: + +```bash +touch vendor/upstream-app/.env + +cat > vendor/upstream-app/.crossignore <<'EOF' +.env +EOF +``` + +Current behavior: + +- `git cross status` reports `Override` +- `git cross diff vendor/upstream-app` prints a manual compare command for `.env` + +Treat this as a review aid. Local-only files still need human review before any upstream push. + +## Step 6: Pull New Upstream Changes Later + +```bash +git cross sync vendor/upstream-app +``` + +Then review again: + +```bash +git cross status +git cross diff vendor/upstream-app +``` + +## Step 7: Publish To Your Own Repo + +Your own product repository is still plain Git: + +```bash +git add vendor/upstream-app +git commit -m "Update vendored upstream app" +git push origin main +``` + +## Step 8: Contribute Back Upstream + +If some of your changes belong upstream: + +1. review `git cross diff vendor/upstream-app` +2. make sure local-only files are not part of the upstream contribution +3. prefer tracking a writable fork for the patch remote + +Then push: + +```bash +git cross push vendor/upstream-app --message "Fix upstream issue" +``` + +After that, open a PR or MR from your fork branch to the original project. + +## When This Pattern Is Better Than A Subdirectory Patch + +Use whole-upstream vendoring when: + +- the upstream project is small enough to vendor completely +- you need several directories from the upstream project together +- the upstream build or runtime layout assumes repo-wide paths + +Use a subdirectory patch when: + +- you only need one component +- you want a smaller local footprint +- you want AI or sandbox tooling scoped to a smaller subtree diff --git a/sbx-kits/README.md b/sbx-kits/README.md new file mode 100644 index 000000000..628264a1b --- /dev/null +++ b/sbx-kits/README.md @@ -0,0 +1,97 @@ +# Sandbox Kits + +This repository keeps sandbox starter kits under `sbx-kits/` so they can be versioned with the codebase. + +Use repo-local paths such as `./sbx-kits/opencode`, not user-home examples like `~/.config/agx/...`. + +## Why Keep Kits In The Repo + +- easier for cautious users to try `git-cross` in an isolated environment +- sandbox setup stays reviewable in pull requests +- agent config and repo guidance evolve with the project +- contributors can run the same kit without global setup drift + +## Layout + +```text +sbx-kits/ +├── README.md +├── claude/ +│ ├── spec.yaml +│ └── files/ +│ └── home/ +│ └── .claude/ +│ └── README.md +└── opencode/ + ├── spec.yaml + └── files/ + └── home/ + └── .config/ + └── opencode/ + └── README.md +``` + +General `sbx` kit structure: + +```text +my-kit/ + spec.yaml + files/ + home/ + workspace/ +``` + +## Current Starter Kits + +Run them with: + +```bash +sbx run opencode --kit ./sbx-kits/opencode +sbx run claude --kit ./sbx-kits/claude +``` + +These starters are intentionally small. + +They are meant to be a safe base for: + +- sandbox-local OpenCode config +- sandbox-local Claude config +- repo-local skill loading from `.opencode/skills/` +- repo-local Claude entrypoints from `.claude/` +- experiments where only a vendored subdirectory is mounted into the sandbox + +## Recommended Workflow + +1. Set up `git-cross` on the host. +2. Patch the upstream content into a local directory such as `vendor/lib`. +3. Start a sandbox with the repo-local kit. +4. Mount only the vendored path you want the agent to edit. +5. Review the result on the host with `git cross diff` and `git cross status`. +6. Publish to your own repo or push upstream separately. + +## Notes + +- `files/home/` maps to `/home/agent/` in the sandbox. +- `files/workspace/` maps into the main workspace mount. +- keep secrets out of the kit files unless you are using a proper credential mechanism +- if you hit the current `sbx` static-files issue, keep the kit minimal and prefer runtime-mounted files until that bug is resolved + +## Related Repo Files + +- `.agents/` +- `.claude/` +- `.opencode/skills/` +- `docs/tutorials/migrating-private-fork-to-git-cross.md` +- `docs/tutorials/whole-upstream-into-local-dir.md` +- `docs/tutorials/local-overlays-and-upstream.md` + +## References + +- Docker Sandboxes: https://docs.docker.com/ai/sandboxes/ +- Kits docs: https://docs.docker.com/ai/sandboxes/customize/kits/ +- Kit examples: https://docs.docker.com/ai/sandboxes/customize/kit-examples/ +- Reference kits repo: https://github.com/docker/sbx-kits-contrib +- Claude Code skills: https://code.claude.com/docs/en/skills +- Claude Code additional directories: https://code.claude.com/docs/en/memory#load-from-additional-directories +- OpenCode skills: https://opencode.ai/docs/skills/ +- Known `sbx` static-files bug: https://github.com/docker/sbx-releases/issues/133 diff --git a/sbx-kits/claude/files/home/.claude/README.md b/sbx-kits/claude/files/home/.claude/README.md new file mode 100644 index 000000000..44c305e50 --- /dev/null +++ b/sbx-kits/claude/files/home/.claude/README.md @@ -0,0 +1,12 @@ +# Claude Sandbox Notes + +This directory is copied into `/home/agent/.claude/` when the `sbx-kits/claude` kit is used. + +The starter kit is intentionally minimal. + +Keep durable repo guidance in the repository itself: + +- `/workspace/.agents/` +- `/workspace/.claude/` + +That way the sandbox setup stays small and the actual repo-specific instructions stay versioned with the project. diff --git a/sbx-kits/claude/spec.yaml b/sbx-kits/claude/spec.yaml new file mode 100644 index 000000000..dff6f7283 --- /dev/null +++ b/sbx-kits/claude/spec.yaml @@ -0,0 +1,5 @@ +schemaVersion: "1" +kind: mixin +name: git-cross-claude +displayName: git-cross Claude Starter +description: Adds a repo-local starter layout for running Claude in Docker sbx with git-cross repo guidance. diff --git a/sbx-kits/opencode/spec.yaml b/sbx-kits/opencode/spec.yaml new file mode 100644 index 000000000..b08b96a63 --- /dev/null +++ b/sbx-kits/opencode/spec.yaml @@ -0,0 +1,5 @@ +schemaVersion: "1" +kind: mixin +name: git-cross-opencode +displayName: git-cross OpenCode Starter +description: Adds a repo-local starter layout for running OpenCode in Docker sbx with git-cross guidance. diff --git a/specs/002-root-overlay-patching/plan.md b/specs/002-root-overlay-patching/plan.md new file mode 100644 index 000000000..37708a614 --- /dev/null +++ b/specs/002-root-overlay-patching/plan.md @@ -0,0 +1 @@ +# Implementation Plan: root overlay patching and `.crossignore`\n\n**Branch**: `002-root-overlay-patching` | **Date**: 2026-05-27 | **Spec**: [`spec.md`](./spec.md)\n\n## Summary\n\nAdd a safe overlay model for repo-root patching so `git-cross` can mix upstream-managed files with local-only files in the same repository without leaking or deleting protected files.\n\n## Review Conclusion\n\nThe product direction is valid and expands `git-cross` from subtree vendoring into a stronger \"mix repos safely\" workflow.\n\nThe current implementation is not ready for that workflow because it still assumes directory mirroring in multiple commands.\n\n## Implementation Requirements\n\n### Behavior Model\n\n- Introduce an explicit concept of an upstream-managed file set.\n- Root-target patches must operate on that managed file set, not on full-directory mirroring.\n- `.crossignore` must define a protected local-only file set excluded from upstream operations.\n- Support local overrides: files that replace upstream versions without syncing back, but with diff visibility.\n\n### Command Requirements\n\n- `patch`: materialize managed files into target root without deleting protected local-only files.\n- `sync`: pull upstream changes into the managed set only, skipping overrides.\n- `push`: send only managed non-ignored files back to worktree, skipping overrides.\n- `status`: compare only managed non-ignored files, but flag override diffs.\n- `diff`: compare only managed non-ignored files, but show override diffs with markers.\n- `remove`: unregister the patch and clean cross state without deleting repo root contents.\n- `prune`: remove worktrees and metadata safely, with the same root protections.\n\n### Storage Requirements\n\n- Extend metadata to record patch mode and any ignore-related information needed for deterministic behavior.\n- Keep Crossfile replay syntax explicit enough to reconstruct root-target overlay patches.\n- Normalize all stored paths to repo-root-relative form.\n\n### Safety Requirements\n\n- Replace delete-heavy rsync assumptions for root-target patches.\n- Refuse root-target actions when path resolution is ambiguous.\n- Add explicit root guards in `remove` and `prune`.\n- Preserve metadata consistency on command failure.\n\n## Recommended Technical Shape\n\n### Phase 1: Safety Foundations\n\n- Fix path normalization in all implementations.\n- Add root guards to `remove` and `prune`.\n- Align metadata schema across implementations, including Go `id` handling.\n\n### Phase 2: Ignore Model\n\n- Add `.crossignore` parsing and shared matching semantics.\n- Apply ignore semantics to status/diff first, then sync/push.\n\n### Phase 3: Override Model\n\n- Extend `.crossignore` with override syntax (e.g., `!override .env`).\n- Implement diff/status to compare overrides against upstream without including in sync/push.\n\n### Phase 4: Overlay Sync Model\n\n- Build managed-file-set aware patch/sync/push behavior.\n- Ensure upstream deletions affect only managed files.\n- Preserve ignored local-only files across all flows.\n\n### Phase 5: UX And Docs\n\n- Simplify CLI examples for root-target usage.\n- Add README examples for whole-upstream-to-root and upstream-subdir-to-root.\n- Document `.crossignore` clearly as a safety boundary.\n- Document override usage with examples (e.g., custom .env).\n\n## Risks\n\n- Reusing current rsync flows without a managed-file-set model will leak or delete files.\n- Root-target path ambiguity can corrupt user workspaces.\n- Partial implementation in only one command will create dangerous inconsistency.\n- Override model complexity could confuse users if syntax is not intuitive.\n\n## Verification Strategy\n\n- Add focused regression tests for root-target patching.\n- Add `.crossignore` protection tests.\n- Add destructive-safety tests for `remove` and `prune`.\n- Add override diff/status tests without sync/push leakage.\n- Run parity coverage across Just, Go, and Rust. diff --git a/specs/002-root-overlay-patching/research.md b/specs/002-root-overlay-patching/research.md new file mode 100644 index 000000000..26c587022 --- /dev/null +++ b/specs/002-root-overlay-patching/research.md @@ -0,0 +1,17 @@ +# Research Notes: root overlay patching and `.crossignore` + +## Current Blocking Findings + +- `patch` and `push` currently behave like directory mirrors. +- Go `sync` still deletes local-only files on worktree-to-local sync. +- `status` and `diff` currently compare full trees instead of a managed file set. +- `remove` and `prune` are unsafe when `local_path` is `.`. + +## Product Insight + +This feature is not just "patch root". It introduces a second usage model: + +- subtree mirror mode for vendoring +- root overlay mode for mixing upstream and local-only files safely + +The implementation should make that distinction explicit. diff --git a/specs/002-root-overlay-patching/spec.md b/specs/002-root-overlay-patching/spec.md new file mode 100644 index 000000000..8888cf9e9 --- /dev/null +++ b/specs/002-root-overlay-patching/spec.md @@ -0,0 +1 @@ +# Feature Specification: root overlay patching and `.crossignore`\n\n**Feature Branch**: `002-root-overlay-patching` \n**Created**: 2026-05-27 \n**Status**: Draft \n**Input**: User description requesting repo-root patching, upstream subdir-to-root patching, protected local-only files, and safer selective/batch operations.\n\n## User Problem\n\nUsers want to mix an upstream repository with their own repository while keeping local-only files in the same working tree.\n\nTypical examples:\n\n- Use the whole upstream repository at local repo root.\n- Use an upstream subdirectory at local repo root.\n- Keep local-only files such as secrets, machine config, and private overlays out of upstream history forever.\n\nThe current mirror-oriented `patch` model does not safely solve this.\n\n## Scope\n\n- Support upstream root -> local repo root.\n- Support upstream subdirectory -> local repo root.\n- Protect local-only files through `.crossignore`.\n- Make `sync`, `push`, `status`, `diff`, `remove`, and `prune` safe for root-target patches.\n- Preserve selective per-path operations and batch-all operations.\n\n## Non-Goals\n\n- This feature does not require single-file patch support.\n- This feature does not change the project into a general dependency manager.\n- This feature does not permit any operation that can silently delete unrelated repo-root data.\n\n## User Scenarios & Testing\n\n### User Story 1 - Upstream root to local root\n\nA user can patch the root of an upstream repository into the root of their current repository without losing local-only files.\n\n**Acceptance Scenarios**:\n\n1. **Given** a repo with a configured upstream alias and a `.crossignore` file, **When** the user patches upstream root to local root, **Then** upstream-managed files appear in repo root and ignored local-only files remain untouched.\n2. **Given** the same patch, **When** the user runs `sync`, **Then** upstream additions, edits, and deletions apply only to upstream-managed files and ignored local-only files remain untouched.\n\n### User Story 2 - Upstream subdirectory to local root\n\nA user can patch an upstream subdirectory into local repo root and treat that subtree as the upstream-managed base of the project.\n\n**Acceptance Scenarios**:\n\n1. **Given** an upstream repo with `app/` and a local repo root target, **When** the user patches `remote:branch:app` to `.`, **Then** only the upstream subtree is materialized into local root.\n2. **Given** local-only files excluded by `.crossignore`, **When** the user runs `push`, **Then** only upstream-managed files are copied back to the worktree and ignored files never appear in the upstream commit.\n\n### User Story 3 - Safe status, diff, remove, and prune\n\nA user can inspect and manage root-target patches without destructive side effects.\n\n**Acceptance Scenarios**:\n\n1. **Given** a root-target patch, **When** the user runs `status` or `diff`, **Then** ignored local-only files are excluded from upstream-comparison noise.\n2. **Given** a root-target patch, **When** the user runs `remove` or `prune`, **Then** the command refuses to delete repository root contents and only removes cross-managed metadata/worktrees.\n\n### User Story 4 - Local overrides of upstream files\n\nA user can maintain local versions of upstream files (e.g., .env) that override the upstream without pushing local changes back, while still seeing diffs if upstream changes.\n\n**Acceptance Scenarios**:\n\n1. **Given** an upstream file like .env marked as override in .crossignore, **When** sync runs, **Then** local .env is not overwritten by upstream.\n2. **Given** the same setup, **When** push runs, **Then** local .env is not copied to worktree or pushed upstream.\n3. **Given** upstream changes to .env, **When** status or diff runs, **Then** the user sees a notification of upstream changes vs local override.\n\n## Requirements\n\n### Functional Requirements\n\n- **FR-001**: The tool MUST support a patch whose `local_path` is `.` without mirroring destructive deletes into unrelated repo-root files.\n- **FR-002**: The tool MUST support patching upstream root into local root.\n- **FR-003**: The tool MUST support patching an upstream subdirectory into local root.\n- **FR-004**: The tool MUST introduce a `.crossignore` file using gitignore-like patterns to exclude local-only files from cross-managed sync, push, diff, and status operations.\n- **FR-005**: For root-target patches, the tool MUST treat ignored files as protected local files that are never copied into the worktree and never deleted by cross-managed sync.\n- **FR-006**: `sync` MUST update only upstream-managed files for root-target patches and MUST preserve ignored local-only files.\n- **FR-007**: `push` MUST copy only upstream-managed, non-ignored files from local workspace to worktree before commit/push.\n- **FR-008**: `status` and `diff` MUST ignore `.crossignore`-matched files when comparing local workspace with the worktree, unless marked as overrides, in which case show diff with special status.\n- **FR-009**: `remove` and `prune` MUST refuse any operation that would recursively delete repository root contents.\n- **FR-010**: Path resolution and metadata lookup MUST be normalized against repository root across all three implementations.\n- **FR-011**: Explicit path-scoped commands and batch-all commands MUST both continue to work for root-target and non-root patches.\n- **FR-012**: The feature MUST be implemented with behavior parity in `Justfile.cross`, `src-go/main.go`, and `src-rust/src/main.rs`.\n- **FR-013**: `.crossignore` MUST support syntax for local overrides (e.g., !override pattern) that exclude from sync/push but include in diff/status with upstream comparison.\n\n### Safety Requirements\n\n- **SR-001**: No command may silently delete files outside the cross-managed upstream file set.\n- **SR-002**: No command may cause `.crossignore`-matched files to enter upstream git history.\n- **SR-003**: Root-target operations MUST fail fast when the implementation cannot prove the operation is safe.\n- **SR-004**: The tool MUST keep `Crossfile` and `.cross/metadata.json` consistent after failed root-target operations.\n\n## Open UX Decisions\n\n- Whether root-target behavior should reuse `patch` with clearer rules or introduce an explicit mode flag.\n- Whether `.crossignore` should be required only for root-target patches or supported for all patches.\n- Whether ignored-file protection should include the effective contents of `.gitignore` by default or only `.crossignore`.\n- Syntax for distinguishing full ignores vs local overrides in `.crossignore` (e.g., prefix markers).\n\n## Success Criteria\n\n- **SC-001**: A user can maintain a repo-root patch with ignored secret files and complete `patch`, `sync`, `status`, `diff`, and `push` without leaking ignored files upstream.\n- **SC-002**: `remove` and `prune` cannot delete repo root contents in any tested root-target scenario.\n- **SC-003**: All three implementations pass the same root-target regression suite.\n- **SC-004**: The final CLI behavior is simple enough to explain with one short README example for upstream-root mode and one for upstream-subdir-to-root mode.\n- **SC-005**: A user can configure local overrides for upstream files and see upstream changes via diff/status without auto-merging or pushing local versions. diff --git a/specs/002-root-overlay-patching/tasks.md b/specs/002-root-overlay-patching/tasks.md new file mode 100644 index 000000000..ad57a47c3 --- /dev/null +++ b/specs/002-root-overlay-patching/tasks.md @@ -0,0 +1,44 @@ +# Tasks: root overlay patching and `.crossignore` + +## Phase 1: Specification And Safety Baseline + +- [x] T001 Write feature spec for repo-root overlay patching and `.crossignore`. +- [x] T002 Write implementation plan describing the managed-file-set model and safety constraints. +- [ ] T003 Decide final CLI shape for root-target patches. +- [ ] T004 Decide whether `.crossignore` applies only to root-target patches or all patch modes. + +## Phase 2: Shared Safety Fixes + +- [ ] T005 Add repo-root normalization helpers where missing in Just, Go, and Rust. +- [ ] T006 Add hard guards preventing `remove` and `prune` from deleting repo root contents. +- [ ] T007 Align metadata schema across implementations, including Go support for `id`. + +## Phase 3: `.crossignore` + +- [ ] T008 Define `.crossignore` parsing and matching semantics. +- [ ] T009 Apply ignore semantics to `status` and `diff` in all three implementations. +- [ ] T010 Apply ignore semantics to `sync` and `push` in all three implementations. +- [ ] T011 Document `.crossignore` in README and examples. + +## Phase 4: Root Overlay Behavior + +- [ ] T012 Implement upstream-root -> local-root patching safely in Just. +- [ ] T013 Port upstream-root -> local-root patching to Go. +- [ ] T014 Port upstream-root -> local-root patching to Rust. +- [ ] T015 Implement upstream-subdir -> local-root patching safely in Just. +- [ ] T016 Port upstream-subdir -> local-root patching to Go. +- [ ] T017 Port upstream-subdir -> local-root patching to Rust. + +## Phase 5: Tests + +- [ ] T018 Add shell regression tests for root-target patching. +- [ ] T019 Add Go regression coverage for root-target patching. +- [ ] T020 Add Rust regression coverage for root-target patching. +- [ ] T021 Add safety tests ensuring ignored files never appear in upstream commits. +- [ ] T022 Add safety tests ensuring `remove` and `prune` do not delete repo root contents. + +## Phase 6: UX And Rollout + +- [ ] T023 Improve README usage examples for the new workflow. +- [ ] T024 Update contributor docs and context files after implementation lands. +- [ ] T025 Run full regression suite and capture remaining implementation gaps. diff --git a/src-go/main.go b/src-go/main.go index 7396c5f57..ac3dff08e 100644 --- a/src-go/main.go +++ b/src-go/main.go @@ -46,6 +46,38 @@ func getCrossfilePath() (string, error) { return filepath.Join(root, CrossfileRelPath), nil } +func parseCrossOverrides(data string) []string { + var overrides []string + for _, line := range strings.Split(data, "\n") { + line = strings.TrimSpace(line) + if line == "" || strings.HasPrefix(line, "#") { + continue + } + overrides = append(overrides, line) + } + return overrides +} + +func getCrossOverrides(localPath string) ([]string, error) { + data, err := os.ReadFile(filepath.Join(localPath, ".crossignore")) + if err != nil { + if os.IsNotExist(err) { + return nil, nil + } + return nil, err + } + return parseCrossOverrides(string(data)), nil +} + + +func hasCrossOverrides(localPath string) (bool, error) { + overrides, err := getCrossOverrides(localPath) + if err != nil { + return false, err + } + return len(overrides) > 0, nil +} + type Patch struct { Remote string `json:"remote"` RemotePath string `json:"remote_path"` @@ -356,10 +388,12 @@ func removeSinglePatch(meta *Metadata, localPath string) error { logInfo("Updating metadata...") meta.Patches = append(meta.Patches[:patchIdx], meta.Patches[patchIdx+1:]...) - // 4. Remove local directory - logInfo(fmt.Sprintf("Deleting local directory %s...", localPath)) - if err := os.RemoveAll(localPath); err != nil { - logError(fmt.Sprintf("Failed to remove local directory: %v", err)) + // 4. Remove local directory (with root guard) + if localPath != "." && localPath != "" { + logInfo(fmt.Sprintf("Deleting local directory %s...", localPath)) + if err := os.RemoveAll(localPath); err != nil { + logError(fmt.Sprintf("Failed to remove local directory: %v", err)) + } } return nil @@ -1136,12 +1170,20 @@ func main() { if _, err := os.Stat(worktreePath); os.IsNotExist(err) { diff = "Missing WT" } else { - // Quick diff check - use absolute paths - upstreamPath := filepath.Join(worktreePath, p.RemotePath) - localPath := filepath.Join(root, p.LocalPath) - c := exec.Command("git", "diff", "--no-index", "--quiet", upstreamPath, localPath) - if err := c.Run(); err != nil { - diff = "Modified" + hasOverrides, err := hasCrossOverrides(filepath.Join(root, p.LocalPath)) + if err != nil { + return fmt.Errorf("failed to read .crossignore for %s: %w", p.LocalPath, err) + } + if hasOverrides { + diff = "Override" + } else { + // Quick diff check - use absolute paths + upstreamPath := filepath.Join(worktreePath, p.RemotePath) + localPath := filepath.Join(root, p.LocalPath) + c := exec.Command("git", "diff", "--no-index", "--quiet", upstreamPath, localPath) + if err := c.Run(); err != nil { + diff = "Modified" + } } behindOut, _ := git.NewCommand("rev-list", "--count", "HEAD..@{upstream}").RunInDir(worktreePath) @@ -1229,14 +1271,24 @@ patch's diff. Otherwise shows diffs for all patches.`, } // Use git diff --no-index to compare directories - // Both paths must be resolved relative to repo root upstreamPath := filepath.Join(worktreePath, p.RemotePath) localPath := filepath.Join(root, p.LocalPath) - c := exec.Command("git", "diff", "--no-index", upstreamPath, localPath) - c.Stdout = os.Stdout - c.Stderr = os.Stderr - // git diff --no-index returns 1 if there are differences, which cobra might treat as error - _ = c.Run() + overrides, err := getCrossOverrides(localPath) + if err != nil { + return fmt.Errorf("failed to read .crossignore for %s: %w", p.LocalPath, err) + } + if len(overrides) > 0 { + logInfo(fmt.Sprintf(".crossignore overrides present in %s; review manually:", p.LocalPath)) + for _, override := range overrides { + fmt.Printf("git diff --no-index %q %q\n", filepath.Join(upstreamPath, override), filepath.Join(localPath, override)) + } + } else { + c := exec.Command("git", "diff", "--no-index", upstreamPath, localPath) + c.Stdout = os.Stdout + c.Stderr = os.Stderr + // git diff --no-index returns 1 if there are differences, which cobra might treat as error + _ = c.Run() + } } if !found && path != "" { return fmt.Errorf("patch not found for path: %s", path) diff --git a/src-rust/Cargo.lock b/src-rust/Cargo.lock index fb3e0089b..1c0e0c5db 100644 --- a/src-rust/Cargo.lock +++ b/src-rust/Cargo.lock @@ -64,6 +64,15 @@ version = "2.10.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "812e12b5285cc515a9c72a5c1d3b6d46a19dac5acfef5265968c166106e31dd3" +[[package]] +name = "block-buffer" +version = "0.10.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3078c7629b62d3f0439517fa394996acacc5cbc91c5a20d8c658e77abd503a71" +dependencies = [ + "generic-array", +] + [[package]] name = "bytecount" version = "0.6.9" @@ -134,6 +143,35 @@ version = "1.0.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b05b61dc5112cbb17e4b6cd61790d9845d13888356391624cbe7e41efeac1e75" +[[package]] +name = "cpufeatures" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "59ed5838eebb26a2bb2e58f6d5b5316989ae9d08bab10e0e6d103e656d1b0280" +dependencies = [ + "libc", +] + +[[package]] +name = "crypto-common" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "78c8292055d1c1df0cce5d180393dc8cce0abec0a7102adb6c7b1eef6016d60a" +dependencies = [ + "generic-array", + "typenum", +] + +[[package]] +name = "digest" +version = "0.10.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292" +dependencies = [ + "block-buffer", + "crypto-common", +] + [[package]] name = "displaydoc" version = "0.2.5" @@ -194,6 +232,16 @@ dependencies = [ "percent-encoding", ] +[[package]] +name = "generic-array" +version = "0.14.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85649ca51fd72272d7821adaf274ad91c288277713d9c18820d8499a7ff69e9a" +dependencies = [ + "typenum", + "version_check", +] + [[package]] name = "getrandom" version = "0.3.4" @@ -208,7 +256,7 @@ dependencies = [ [[package]] name = "git-cross-rust" -version = "0.2.1" +version = "0.3.0" dependencies = [ "anyhow", "clap", @@ -216,7 +264,7 @@ dependencies = [ "git2", "serde", "serde_json", - "shell-words", + "sha2", "tabled", "which", ] @@ -633,6 +681,17 @@ dependencies = [ "serde_core", ] +[[package]] +name = "sha2" +version = "0.10.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a7507d819769d01a365ab707794a4084392c824f54a7a6a7862f8c3d0892b283" +dependencies = [ + "cfg-if", + "cpufeatures", + "digest", +] + [[package]] name = "shared_child" version = "1.1.1" @@ -644,12 +703,6 @@ dependencies = [ "windows-sys 0.60.2", ] -[[package]] -name = "shell-words" -version = "1.1.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "dc6fe69c597f9c37bfeeeeeb33da3530379845f10be461a66d16d03eca2ded77" - [[package]] name = "shlex" version = "1.3.0" @@ -771,6 +824,12 @@ dependencies = [ "zerovec", ] +[[package]] +name = "typenum" +version = "1.20.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "40ce102ab67701b8526c123c1bab5cbe42d7040ccfd0f64af1a385808d2f43de" + [[package]] name = "unicode-ident" version = "1.0.22" diff --git a/src-rust/src/main.rs b/src-rust/src/main.rs index d2b0590e6..3dddf9adf 100644 --- a/src-rust/src/main.rs +++ b/src-rust/src/main.rs @@ -131,6 +131,32 @@ fn get_crossfile_path() -> Result { Ok(Path::new(&root).join(CROSSFILE_REL_PATH)) } +fn parse_cross_overrides(content: &str) -> Vec { + let mut overrides = Vec::new(); + for line in content.lines() { + let trimmed = line.trim(); + if trimmed.is_empty() || trimmed.starts_with('#') { + continue; + } + overrides.push(trimmed.to_string()); + } + overrides +} + +fn get_cross_overrides(local_path: &Path) -> Result> { + let path = local_path.join(".crossignore"); + if !path.exists() { + return Ok(Vec::new()); + } + + let content = fs::read_to_string(path)?; + Ok(parse_cross_overrides(&content)) +} + +fn has_cross_overrides(local_path: &Path) -> Result { + Ok(!get_cross_overrides(local_path)?.is_empty()) +} + fn parse_patch_spec(spec: &str) -> Result { let parts: Vec<&str> = spec.split(':').collect(); if parts.len() < 2 { @@ -644,10 +670,12 @@ fn remove_single_patch(metadata: &mut Metadata, local_path: &str) -> Result<()> log_info("Removing from Crossfile..."); remove_from_crossfile(&path); - // 3. Remove local directory - log_info(&format!("Deleting local directory {}...", path)); - if let Err(e) = fs::remove_dir_all(&path) { - log_error(&format!("Failed to remove local directory: {}", e)); + // 3. Remove local directory (with root guard) + if path != "." && !path.is_empty() { + log_info(&format!("Deleting local directory {}...", path)); + if let Err(e) = fs::remove_dir_all(&path) { + log_error(&format!("Failed to remove local directory: {}", e)); + } } Ok(()) @@ -1229,24 +1257,28 @@ fn main() -> Result<()> { if !worktree_path.exists() { row.diff = "Missing WT".to_string(); } else { - // Both paths must be resolved relative to repo root - let upstream_path = worktree_path.join(&patch.remote_path); - let local_path = Path::new(&root).join(&patch.local_path); - - let diff_check = duct::cmd( - "git", - [ - "diff", - "--no-index", - "--quiet", - &upstream_path.to_string_lossy(), - &local_path.to_string_lossy(), - ], - ) - .unchecked() - .run()?; - if !diff_check.status.success() { - row.diff = "Modified".to_string(); + if has_cross_overrides(&Path::new(&root).join(&patch.local_path))? { + row.diff = "Override".to_string(); + } else { + // Both paths must be resolved relative to repo root + let upstream_path = worktree_path.join(&patch.remote_path); + let local_path = Path::new(&root).join(&patch.local_path); + + let diff_check = duct::cmd( + "git", + [ + "diff", + "--no-index", + "--quiet", + &upstream_path.to_string_lossy(), + &local_path.to_string_lossy(), + ], + ) + .unchecked() + .run()?; + if !diff_check.status.success() { + row.diff = "Modified".to_string(); + } } let behind = run_cmd(&[ @@ -1442,11 +1474,33 @@ fn main() -> Result<()> { // Both paths must be resolved relative to repo root let upstream_path = worktree_path.join(&patch.remote_path); let local_path = Path::new(&root).join(&patch.local_path); - - // git diff --no-index returns 1 on differences, duct handles it via unchecked() if we want to ignore exit code - let _ = duct::cmd("git", ["diff", "--no-index", - &upstream_path.to_string_lossy(), - &local_path.to_string_lossy()]).run(); + + let overrides = get_cross_overrides(&local_path)?; + if !overrides.is_empty() { + log_info(&format!( + ".crossignore overrides present in {}; review manually:", + patch.local_path + )); + for override_path in overrides { + println!( + "git diff --no-index {:?} {:?}", + upstream_path.join(&override_path), + local_path.join(&override_path) + ); + } + } else { + let _ = duct::cmd( + "git", + [ + "diff", + "--no-index", + &upstream_path.to_string_lossy(), + &local_path.to_string_lossy(), + ], + ) + .unchecked() + .run(); + } } if !found && !resolved_path.is_empty() { return Err(anyhow!("Patch not found for path: {}", resolved_path)); diff --git a/test/007_status.sh b/test/007_status.sh index 018a334c0..01189e638 100755 --- a/test/007_status.sh +++ b/test/007_status.sh @@ -43,6 +43,15 @@ check_status() { # ------------------------------------------------------------------ check_status "vendor/docs" "Clean.*Synced" +# ------------------------------------------------------------------ +# Test 1b: override markers switch status to override review +# ------------------------------------------------------------------ +cat > vendor/docs/.crossignore <<'EOF' +.env +EOF +check_status "vendor/docs" "Override.*Synced" +rm -f vendor/docs/.crossignore + # ------------------------------------------------------------------ # Test 2: Modified (Local change) # ------------------------------------------------------------------ diff --git a/test/008_rust_cli.sh b/test/008_rust_cli.sh index 52051e949..f21bee8d3 100755 --- a/test/008_rust_cli.sh +++ b/test/008_rust_cli.sh @@ -137,6 +137,27 @@ fi # Revert the change for clean state echo "Updated logic" > vendor/rust-src/logic.rs +log_header "Testing Rust '.crossignore' override status/diff hint..." +cat > vendor/rust-src/.crossignore <<'EOF' +.env +EOF +status_output=$("$RUST_BIN" status 2>&1) +echo "$status_output" +status_line=$(echo "$status_output" | grep "vendor/rust-src" || true) +if ! echo "$status_line" | grep -q "Override"; then + fail "Rust 'status' should mark override patches as Override" +fi + +diff_output=$("$RUST_BIN" diff vendor/rust-src 2>&1 || true) +echo "$diff_output" +if ! echo "$diff_output" | grep -q ".crossignore overrides present in vendor/rust-src"; then + fail "Rust 'diff' should mention override review when markers exist" +fi +if ! echo "$diff_output" | grep -q '.env'; then + fail "Rust 'diff' should print manual override file command" +fi +rm -f vendor/rust-src/.crossignore + log_header "Testing Rust 'push' command..." # Allow pushing to current branch in mock upstream pushd "$upstream_path" >/dev/null @@ -241,4 +262,20 @@ if [ -d "vendor/prune-lib" ]; then fi log_success "Rust prune test passed" +log_header "Testing Rust root-target remove guard..." +cat > Crossfile <<'EOF' +# git-cross configuration +cross patch root-remote:main:. . +EOF +mkdir -p .cross +cat > .cross/metadata.json <<'EOF' +{"patches":[{"id":"root1234","remote":"root-remote","remote_path":".","local_path":".","worktree":".cross/worktrees/root-remote_root1234","branch":"main"}]} +EOF +echo "keep me" > keep-root-rust.txt + +"$RUST_BIN" remove . +if [ ! -f "keep-root-rust.txt" ]; then + fail "Rust root-target remove deleted repo root contents" +fi + echo "Rust implementation tests passed!" diff --git a/test/009_go_cli.sh b/test/009_go_cli.sh index 495ce197d..34610947b 100755 --- a/test/009_go_cli.sh +++ b/test/009_go_cli.sh @@ -183,6 +183,27 @@ fi # Revert the change for clean state echo "Updated go logic" > vendor/go-src/logic.go +log_header "Testing Go '.crossignore' override status/diff hint..." +cat > vendor/go-src/.crossignore <<'EOF' +.env +EOF +status_output=$("$GO_BIN" status 2>&1) +echo "$status_output" +status_line=$(echo "$status_output" | grep "vendor/go-src" || true) +if ! echo "$status_line" | grep -q "Override"; then + fail "Go 'status' should mark override patches as Override" +fi + +diff_output=$("$GO_BIN" diff vendor/go-src 2>&1 || true) +echo "$diff_output" +if ! echo "$diff_output" | grep -q ".crossignore overrides present in vendor/go-src"; then + fail "Go 'diff' should mention override review when markers exist" +fi +if ! echo "$diff_output" | grep -q '.env'; then + fail "Go 'diff' should print manual override file command" +fi +rm -f vendor/go-src/.crossignore + log_header "Testing Go 'push' command..." # Allow pushing to current branch in mock upstream pushd "$upstream_path" >/dev/null @@ -282,4 +303,20 @@ if [ -d "vendor/prune-lib" ]; then fi log_success "Go prune test passed" +log_header "Testing Go root-target remove guard..." +cat > Crossfile <<'EOF' +# git-cross configuration +cross patch root-remote:main:. . +EOF +mkdir -p .cross +cat > .cross/metadata.json <<'EOF' +{"patches":[{"id":"root1234","remote":"root-remote","remote_path":".","local_path":".","worktree":".cross/worktrees/root-remote_root1234","branch":"main"}]} +EOF +echo "keep me" > keep-root-go.txt + +"$GO_BIN" remove . +if [ ! -f "keep-root-go.txt" ]; then + fail "Go root-target remove deleted repo root contents" +fi + echo "Go implementation tests passed!" diff --git a/test/014_remove.sh b/test/014_remove.sh index 7afacec10..f057d32c5 100755 --- a/test/014_remove.sh +++ b/test/014_remove.sh @@ -102,4 +102,25 @@ else echo "SKIP: Rust binary not available, skipping Rust dedup and list tests" fi +# 8. Test root-target remove safety guard (Shell/Just) +echo "## Testing root-target remove safety guard (Shell/Just)..." +setup_sandbox +cd "$SANDBOX" + +cat > Crossfile <<'EOF' +# git-cross configuration +cross patch root-remote:main:. . +EOF +mkdir -p .cross +cat > .cross/metadata.json <<'EOF' +{"patches":[{"id":"root1234","remote":"root-remote","remote_path":".","local_path":".","worktree":".cross/worktrees/root-remote_root1234","branch":"main"}]} +EOF +echo "keep me" > keep.txt + +just cross remove . + +if [ ! -f "keep.txt" ]; then fail "root-target remove deleted repo root contents"; fi +if grep -q 'root-remote:main:. \.$' Crossfile; then fail "root-target remove did not clean Crossfile"; fi +if [ "$(jq -r '.patches | length' .cross/metadata.json)" != "0" ]; then fail "root-target remove did not clean metadata"; fi + echo "Phase 2 validation passed!" diff --git a/test/015_prune.sh b/test/015_prune.sh index 143ac4e14..c8b4abd48 100755 --- a/test/015_prune.sh +++ b/test/015_prune.sh @@ -214,4 +214,33 @@ else echo "SKIP: Go binary not available, skipping Go orphan prune test" fi +###### +# Test 6: Root-target prune safety guard (Shell/Just) +###### +log_header "Test 6: Root-target prune safety guard..." + +setup_sandbox +cd "$SANDBOX" + +root_upstream=$(create_upstream "root-prune") +just cross use root-remote "file://$root_upstream" || fail "Failed to add root-remote" + +cat > Crossfile <<'EOF' +# git-cross configuration +cross patch root-remote:main:. . +EOF +mkdir -p .cross +cat > .cross/metadata.json <<'EOF' +{"patches":[{"id":"root1234","remote":"root-remote","remote_path":".","local_path":".","worktree":".cross/worktrees/root-remote_root1234","branch":"main"}]} +EOF +echo "keep me" > keep.txt + +just cross prune root-remote || fail "Root-target prune failed" + +if [ ! -f "keep.txt" ]; then fail "root-target prune deleted repo root contents"; fi +if git remote | grep -q '^root-remote$'; then fail "root-target prune did not remove remote"; fi +if [ "$(jq -r '.patches | length' .cross/metadata.json)" != "0" ]; then fail "root-target prune did not clean metadata"; fi + +log_success "Test 6 passed: Root-target prune keeps repo root contents" + log_success "All prune tests passed!" diff --git a/test/016_diff_context.sh b/test/016_diff_context.sh index 9eb6fdafa..904387fbc 100755 --- a/test/016_diff_context.sh +++ b/test/016_diff_context.sh @@ -118,5 +118,17 @@ else log_success "Test 7 passed: Go shows all patches from repo root" fi +# --- Test 8: override markers print manual diff commands --- +log_header "Test 8: override markers print manual diff commands..." +cat > vendor/alpha/.crossignore <<'EOF' +.env +EOF +output=$(just cross diff vendor/alpha 2>&1 || true) +echo "$output" +if ! echo "$output" | grep -q '.env'; then + fail "Test 8: diff should print manual override file command" +fi +rm -f vendor/alpha/.crossignore + echo "" echo "All context-aware diff tests passed!"