diff --git a/CHANGELOG.md b/CHANGELOG.md index e5eaaca97..888cd5b29 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -14,12 +14,14 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - **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. +- **Repo split tutorial** - Added `docs/tutorials/split-repo-into-upstream-and-private.md`, a concise walkthrough for splitting one original repo into a clean secret-free upstream plus a private `git-cross`-managed fork. Uses a single shared rule: one `.crossignore` and one `git ls-files --ignored --exclude-from=.crossignore` selection piped to `git rm` (remove from upstream) or `rsync` (back up and keep), so the upstream and the fork can never disagree about what is private. The selection command is documented as a repeatable preview (refine `.crossignore` in a loop until it lists exactly the private files) and clarified as non-destructive — the seed removal only strips the throwaway upstream copy while private files stay in the fork as git-cross overlays. The upstream-seed removal deletes the tracked-only selection (`--cached`, without `--others`) with `rm -rf` followed by `git add -A`, instead of `git rm`: `git rm` aborts the whole batch on untracked/gitignored pathspecs (`node_modules/`, `.envrc.local`) and on submodule/embedded-repo gitlinks (`could not lookup name for submodule ...`), whereas working-tree deletion + restage handles files, directories, and gitlinks uniformly. Documented that `rm` has no `--dry-run` (preview via `xargs … echo` or confirm via `rm -rI`/`-ri` with `xargs -o`), and that `test/` matches a `test` directory at any depth while `*/test/*` does not. The backup keeps `--others` so untracked private files are still copied. Each step is labelled with the repo it runs in. ### 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. +- **`.crossignore` entry syntax** - Override review behavior now uses plain non-comment `.crossignore` lines. Supported forms include basename entries such as `.env` anywhere under the patch, basename globs such as `*.env`, and directory entries such as `config/`. Full `.gitignore` semantics are 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 +- **Migration tutorial backup command** - Removed the broken recommendation to call the internal Just `_crossignore_overrides` recipe during private-fork migration. The backup step now expands `.crossignore` (which is a gitignore-style *pattern* prescription, not a literal file list) against the real working tree with `git ls-files --ignored --exclude-from=.crossignore` piped into `rsync`, so globs and not-yet-present paths no longer cause `rsync --files-from` "No such file or directory" failures. Documented the safer "clean upstream seed repo first, then patch into the private repo" workflow. - **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`). diff --git a/Justfile.cross b/Justfile.cross index 54de7136d..9cb2b9e23 100644 --- a/Justfile.cross +++ b/Justfile.cross @@ -140,7 +140,7 @@ update_crossfile +cmd: grep -qF "{{cmd}}" "{{CROSSFILE}}" 2>/dev/null || echo "{{cmd}}" >> "{{CROSSFILE}}"; exit 0 [no-cd] -_crossignore_overrides local_dir: +_crossignore_patterns local_dir: #!/usr/bin/env fish set -l file "{{local_dir}}/.crossignore" if not test -f "$file" @@ -153,6 +153,52 @@ _crossignore_overrides local_dir: end end < "$file" +[no-cd] +_crossignore_match pattern rel_path: + #!/usr/bin/env fish + set -l normalized (string trim -r -c '/' -- "{{pattern}}") + if test -z "$normalized" + exit 1 + end + if string match -q '*/*' -- "$normalized" + if test "{{rel_path}}" = "$normalized"; or string match -q -- "$normalized/*" "{{rel_path}}" + printf '%s\n' "$normalized" + exit 0 + end + exit 1 + end + + set -l parts (string split / -- "{{rel_path}}") + for idx in (seq (count $parts)) + if string match -q -- "$normalized" "$parts[$idx]" + string join / $parts[1..$idx] + exit 0 + end + end + exit 1 + +[no-cd] +_crossignore_overrides local_dir: + #!/usr/bin/env fish + set -l patterns (just cross _crossignore_patterns "{{local_dir}}") + if test (count $patterns) -eq 0 + exit 0 + end + + set -l matches + set -l entries (find "{{local_dir}}" -mindepth 1 -not -path '*/.git/*' -print0 | string split0) + for line in $entries + set -l rel (realpath --relative-to="{{local_dir}}" "$line") + for pattern in $patterns + set -l match_path (just cross _crossignore_match "$pattern" "$rel" 2>/dev/null) + if test -n "$match_path" + contains -- "$match_path" $matches; or set matches $matches "$match_path" + break + end + end + end + printf '%s\n' $matches + # Internal: Log message with color _log level +message: #!/usr/bin/env fish diff --git a/README.md b/README.md index 0796f4d4a..341580fd9 100644 --- a/README.md +++ b/README.md @@ -115,6 +115,13 @@ compose.override.yaml EOF ``` +Rule of thumb: + +- a plain basename entry such as `.env` matches that name in any subdirectory under the patch +- a directory entry such as `config` or `config/` matches that directory tree +- a basename glob such as `*.env` also matches anywhere under the patch +- this is intentionally simpler than full `.gitignore` semantics + What this does today: - `git cross status` shows `Override` for that patch @@ -166,6 +173,7 @@ This lets your main repo keep its local opinionated result while the upstream co - [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) +- [Tutorial: Split One Repo Into A Clean Upstream And A Private Overlay](docs/tutorials/split-repo-into-upstream-and-private.md) - [Sandbox Kits](sbx-kits/README.md) ## Commands diff --git a/TODO.md b/TODO.md index b2d0c2273..2c93cd34c 100644 --- a/TODO.md +++ b/TODO.md @@ -22,6 +22,8 @@ ## Completed Tasks +- [x] Fix the private-fork migration tutorial so it no longer points users at the internal `_crossignore_overrides` Just recipe, and document the cleaner "seed a public upstream, then track it from the private repo" migration path. +- [x] Add a standalone tutorial (`docs/tutorials/split-repo-into-upstream-and-private.md`) for splitting one mixed private repo into a clean upstream plus a private git-cross-managed overlay, cross-linked from README and the migration tutorial. - [x] Refactor remaining Rust commands to use `duct` for better error visibility. - [x] Complete `push` command verification in native implementations. - [x] Integrate integration tests (001-009) into a unified test runner. diff --git a/docs/tutorials/migrating-private-fork-to-git-cross.md b/docs/tutorials/migrating-private-fork-to-git-cross.md index 3f8c3d1c9..a77524e56 100644 --- a/docs/tutorials/migrating-private-fork-to-git-cross.md +++ b/docs/tutorials/migrating-private-fork-to-git-cross.md @@ -39,6 +39,53 @@ Assume: - 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/...` +## Before Step 1: Decide Where The Upstream Comes From + +There are two valid starting points. + +### Case A: A Clean Upstream Repo Already Exists + +If you already have a real upstream repository that contains only the shareable code, continue with Step 1. + +### Case B: Your Current Private Repo Is The Only Copy + +If the current private repo is the only place where the code exists and it mixes: + +- upstream-worthy project files +- private files such as `.env` +- machine-local or company-local overrides + +create a clean upstream seed repository first, then come back and migrate the private repo with `git-cross`. + +This split has its own focused walkthrough: +`docs/tutorials/split-repo-into-upstream-and-private.md`. + +The core idea there is a single shared rule: one `.crossignore` lists the private +patterns, and `git ls-files` selects the matching files for both jobs — + +```bash +# back up / keep (rsync): include untracked too +git ls-files -z --cached --others --ignored --exclude-from=.crossignore + +# remove from upstream seed (delete from copy): tracked only +git ls-files -z --cached --ignored --exclude-from=.crossignore +``` + +- the tracked list, deleted with `rm -rf` then `git add -A`, strips those files from the new upstream seed +- the full list piped to `rsync` in your private repo *backs up and keeps* them + +Use `rm` + `git add -A` rather than `git rm` for the seed: `git rm` aborts the +whole batch on untracked pathspecs or on a submodule it cannot name. Deleting from +the working tree and restaging handles files, directories, and submodule gitlinks +uniformly. + +Do the split there, then return here at Step 1 with a clean upstream in hand. + +Important note: + +- if secrets or private files were ever committed into Git history and you plan to publish that history, do a real history rewrite first +- the split tutorial only covers the working-tree split, not secret-history cleanup + ## Step 1: Make A Safety Snapshot Before changing anything, create a backup branch or tag. @@ -63,7 +110,7 @@ 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 +## Step 3: Inventory Local-Only Files And Draft `.crossignore` Make a list of files that must stay private or local-only. @@ -75,52 +122,105 @@ Typical examples: - `config/private/` - machine-local certificate files -For current shipped behavior, define them as explicit `.crossignore` entries. +For current shipped behavior, write them into `.crossignore` first. Current parsing rules are simple: -- each non-empty, non-comment line is treated as one literal override path +- each non-empty, non-comment line is one override pattern +- a plain basename entry such as `.env` matches that name in any subdirectory under the patch - plain entries such as `.env` or `config/private` are supported -- wildcard forms such as `*.env` or `config/*` are **not** supported today +- basename globs such as `*.env` are supported anywhere under the patch +- directory entries such as `config` or `config/` are supported +- full gitignore semantics are **not** supported today Example list: ```text .env -.env.local +*.env docker-compose.override.yml -config/private +config/ ``` -Examples that are **not** currently supported as patterns: +Examples that are still **not** promised as full gitignore-style patterns: ```text -*.env config/* +**/*.env +!negation ``` ## Step 4: Copy Local-Only Files Out Of The Repo Before the first `git-cross` root patch, copy those files outside the repository. -Example: +`.crossignore` is a pattern prescription, not a literal file list. Some lines +are globs (`*.env`) or directories (`config/`), and some patterns may not match +any file that exists yet. So you cannot feed it straight into +`rsync --files-from`; that treats every line as an exact path and fails on the +first pattern or missing file. + +Instead, expand the patterns against the real working tree with `git`, and back +up only the files that actually exist. This needs only `git` and `rsync`, both +already required by `git-cross`: ```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 +git ls-files -z --cached --others --ignored --exclude-from=.crossignore \ + | rsync -av --from0 --files-from=- ./ ../private-overrides-backup/ +``` + +Why this works: + +- `git ls-files --ignored --exclude-from=.crossignore` matches the + gitignore-style patterns against files that exist, so missing patterns are + silently skipped instead of erroring +- `--cached --others` covers both tracked secret files (a committed `.env`) and + untracked ones (an uncommitted `.env.secrets`) +- `-z` / `--from0` handle spaces and unusual filenames safely +- `--files-from` preserves the relative paths, so restore in Step 7 is a plain + `rsync -av ../private-overrides-backup/ ./` + +This step is repeatable. The command is a preview until you pipe it anywhere, so +refine `.crossignore` in a loop: run the preview, read the list, edit +`.crossignore`, run again — repeat until it lists exactly the private files you +want removed from upstream and kept locally. + +```bash +# edit .crossignore, then re-run until the list is exactly right +git ls-files --cached --others --ignored --exclude-from=.crossignore +``` + +Nothing is deleted from your repo here. The backup is only a safety copy; your +private files stay in place and become git-cross overlays after the root patch. +Only the separate upstream seed (a throwaway copy) has them stripped. + +Do not rely on `Justfile.cross` internal helper recipes such as `_crossignore_overrides` for this migration step. Those recipes are implementation internals, not stable user-facing commands, and they are not meant to be invoked directly from another repository. + +If you prefer to be fully explicit, list exact paths instead: + +```bash +mkdir -p ../private-overrides-backup +rsync -avR ./.env ./config/private ../private-overrides-backup/ 2>/dev/null || true ``` If a file is sensitive, verify that your backup location is safe. +Advanced alternatives if you prefer them: + +- create a tar archive of the private files before migration +- temporarily move local-only files out through Git history or branch surgery tools before the root patch + +Those approaches are more invasive. The external backup copy is still the simplest migration checkpoint. + ## 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. +If you created a clean upstream seed repository in the earlier split step, register that repository here. + Example: ```bash @@ -150,10 +250,7 @@ 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 +rsync -av ../private-overrides-backup/ ./ 2>/dev/null || true ``` Then write `.crossignore`: @@ -161,13 +258,13 @@ Then write `.crossignore`: ```bash cat > .crossignore <<'EOF' .env -.env.local +*.env docker-compose.override.yml -config/private +config/ 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. +Use simple explicit patterns. The current code treats `.crossignore` here as a small override matcher, not as full gitignore-style pattern matching. ## Step 8: Review The Migrated State diff --git a/docs/tutorials/split-repo-into-upstream-and-private.md b/docs/tutorials/split-repo-into-upstream-and-private.md new file mode 100644 index 000000000..783d9023f --- /dev/null +++ b/docs/tutorials/split-repo-into-upstream-and-private.md @@ -0,0 +1,187 @@ +# Tutorial: Split One Repo Into A Clean Upstream And A Private Overlay + +Start from one **original repo** that mixes shareable code with secrets, and end +with two repos: + +| Role | Path | What it holds | +|------|------|---------------| +| **original repo** | `project/` | where you start: everything mixed together | +| **upstream repo** | `../project-upstream/` | new, clean, publishable, no secrets | +| **private fork** | `project/` (same dir as original) | the original repo, now tracking upstream via git-cross + local overlays | + +The original repo *becomes* the private fork in place. The upstream repo is a new +clean copy you publish. Each step below marks which repo you run it in. + +Use this when the original repo is the only copy of the code. If a clean upstream +already exists, use `docs/tutorials/migrating-private-fork-to-git-cross.md`. + +> Safety: this only splits the working tree, not Git history. If secrets were +> ever committed and you will publish the full history, rewrite it first with +> `git filter-repo` or BFG. Seeding a fresh snapshot repo (below) avoids that — +> the new upstream starts from one clean commit. + +## 1. Snapshot the original repo · *run in: original repo* + +```bash +git checkout -b backup/pre-split && git push origin backup/pre-split +``` + +## 2. Write `.crossignore` · *run in: original repo* + +This one file is the single source of truth. It drives what the upstream seed +drops, what you back up, and the overlay markers — so the sets cannot drift. + +```bash +cat > .crossignore <<'EOF' +.env* +*.env +docker-compose.override.yml +config/ +test/ +EOF +``` + +Patterns are gitignore-style. Match what you mean: + +- `.env` is only the literal file; `.env*` also catches `.env.local` / `.env.secrets` +- `config/` is the whole `config` tree +- `test/` matches a directory named `test` **at any depth** (recursive). Do not + use `*/test/*` — `*` never crosses `/`, so that only matches `test` one level + deep and only its immediate children + +### The shared rule + +Both jobs select files from the same `.crossignore` with `git ls-files`. The only +difference is one flag, because the two destinations handle different file sets: + +```bash +# back up (rsync): include untracked files too — rsync copies whatever exists +git ls-files -z --cached --others --ignored --exclude-from=.crossignore + +# remove from upstream (delete from copy): tracked files only +git ls-files -z --cached --ignored --exclude-from=.crossignore +``` + +The upstream side drops `--others`: untracked or gitignored files +(`node_modules/`, `.envrc.local`, …) are never part of the upstream commit +anyway, so they need no removal. + +The upstream side also deletes with `rm`, **not** `git rm`. `git rm` aborts the +whole command on anything it cannot resolve — an untracked pathspec +(`pathspec ... did not match any files`) or a submodule / embedded repo +(`could not lookup name for submodule ...`). Deleting from the working tree and +re-running `git add -A` sidesteps both and treats files, directories, and +submodule gitlinks uniformly. + +**It is a preview, so refine it in a loop.** Run the command on its own first, +read the output, edit `.crossignore`, run again — repeat until it lists exactly +the files you want to keep private. Only then pipe it anywhere. + +**Nothing is deleted from your repo.** The deletion in step 3 only strips the +*throwaway upstream copy*; your originals never leave the private fork. The split +just sorts files by role: private files stay in the fork, shareable files go +upstream. + +## 3. Create the upstream repo · *original repo → upstream repo* + +Copy the tree out, then delete the tracked files matched by `.crossignore` so +exactly the private files are dropped. + +```bash +# from the original repo +mkdir -p ../project-upstream +rsync -av --exclude .git ./ ../project-upstream/ + +# now work in the upstream repo +cd ../project-upstream +git init -b main +git add -A + +# preview: refine .crossignore until this lists exactly the private files +git ls-files --cached --ignored --exclude-from=.crossignore + +# delete the matched paths (files, dirs, and submodule gitlinks), then restage +git ls-files -z --cached --ignored --exclude-from=.crossignore | xargs -0 -r rm -rf -- +git add -A + +rm -f .crossignore # overlay marker does not belong upstream +git add -A +git commit -m "Initial upstream seed" +git remote add origin git@github.com:your-org/project.git +git push -u origin main + +cd - # back to the original repo +``` + +`rm` has no `--dry-run`. To dry-run the destructive line, print the command +instead of running it, or ask for confirmation: + +```bash +# print what would run, run nothing +git ls-files -z --cached --ignored --exclude-from=.crossignore | xargs -0 -r echo rm -rf -- + +# delete with a confirmation prompt (Linux rm -rI = one prompt; macOS rm -ri = per file) +# xargs -o reopens the terminal so the prompt can read your keypress; drop -f or it skips the prompt +git ls-files -z --cached --ignored --exclude-from=.crossignore | xargs -0 -r -o rm -ri -- +``` + +Verify no secrets survived before pushing: + +```bash +# inside ../project-upstream, before pushing +git ls-files | grep -Ei 'secret|\.env' || echo "no obvious secrets tracked" +``` + +If a private file is still tracked, your pattern missed it — fix `.crossignore` +(e.g. `*.env*` to also catch `.env.secrets`) and re-run the preview + delete. + +## 4. Back up the private files · *run in: original repo* + +Same `.crossignore`, this time with `--others` added so untracked private files +are backed up too, piped to `rsync` (only existing files are copied, so globs and +missing paths cause no error): + +```bash +mkdir -p ../private-overrides-backup +git ls-files -z --cached --others --ignored --exclude-from=.crossignore \ + | rsync -av --from0 --files-from=- ./ ../private-overrides-backup/ +``` + +## 5. Turn the original repo into the private fork · *run in: original repo* + +This is the transition: the original repo now tracks the upstream repo via +git-cross. + +```bash +git cross use upstream git@github.com:your-org/project.git +git cross patch upstream:. . # `upstream:/` is equivalent +``` + +Register a writable fork instead if you plan to contribute back. + +## 6. Restore overlays and review · *run in: private fork* + +```bash +rsync -av ../private-overrides-backup/ ./ 2>/dev/null || true +cat .crossignore # confirm it survived the patch +git cross status # shows `Override` for the root patch +git cross diff . +``` + +## 7. Commit and day-2 workflow · *run in: private fork* + +```bash +git add Crossfile .crossignore . && git commit -m "Track upstream via git-cross" +git push origin main +``` + +- pull upstream: `git cross sync .` +- review: `git cross diff .` / `git cross status` +- contribute upstream: `git cross push . --message "..."` after confirming no + private files are included + +## Related + +- `docs/tutorials/migrating-private-fork-to-git-cross.md` — clean upstream already exists +- `docs/tutorials/whole-upstream-into-local-dir.md` — vendor into `vendor/...` instead of root +- `docs/tutorials/local-overlays-and-upstream.md` — overlay a single subdirectory diff --git a/src-go/main.go b/src-go/main.go index ac3dff08e..51ac7a3c5 100644 --- a/src-go/main.go +++ b/src-go/main.go @@ -8,6 +8,7 @@ import ( "os" "os/exec" "path/filepath" + "sort" "strings" "github.com/fatih/color" @@ -58,6 +59,61 @@ func parseCrossOverrides(data string) []string { return overrides } +func globMatch(pattern, value string) bool { + patternRunes := []rune(pattern) + valueRunes := []rune(value) + pi, vi := 0, 0 + star, match := -1, 0 + + for vi < len(valueRunes) { + if pi < len(patternRunes) && (patternRunes[pi] == valueRunes[vi] || patternRunes[pi] == '?') { + pi++ + vi++ + continue + } + if pi < len(patternRunes) && patternRunes[pi] == '*' { + star = pi + match = vi + pi++ + continue + } + if star != -1 { + pi = star + 1 + match++ + vi = match + continue + } + return false + } + + for pi < len(patternRunes) && patternRunes[pi] == '*' { + pi++ + } + return pi == len(patternRunes) +} + +func matchCrossOverride(pattern, relPath string) (string, bool) { + pattern = strings.TrimSuffix(filepath.ToSlash(pattern), "/") + relPath = filepath.ToSlash(relPath) + if pattern == "" { + return "", false + } + if strings.Contains(pattern, "/") { + if relPath == pattern || strings.HasPrefix(relPath, pattern+"/") { + return pattern, true + } + return "", false + } + + parts := strings.Split(relPath, "/") + for i, part := range parts { + if globMatch(pattern, part) { + return strings.Join(parts[:i+1], "/"), true + } + } + return "", false +} + func getCrossOverrides(localPath string) ([]string, error) { data, err := os.ReadFile(filepath.Join(localPath, ".crossignore")) if err != nil { @@ -66,7 +122,54 @@ func getCrossOverrides(localPath string) ([]string, error) { } return nil, err } - return parseCrossOverrides(string(data)), nil + + patterns := parseCrossOverrides(string(data)) + if len(patterns) == 0 { + return nil, nil + } + + seen := make(map[string]bool) + var overrides []string + err = filepath.WalkDir(localPath, func(path string, d os.DirEntry, err error) error { + if err != nil { + return err + } + if path == localPath { + return nil + } + relPath, err := filepath.Rel(localPath, path) + if err != nil { + return err + } + relPath = filepath.ToSlash(relPath) + if relPath == ".git" || strings.HasPrefix(relPath, ".git/") { + if d.IsDir() { + return filepath.SkipDir + } + return nil + } + + for _, pattern := range patterns { + matchedPath, ok := matchCrossOverride(pattern, relPath) + if !ok { + continue + } + if !seen[matchedPath] { + seen[matchedPath] = true + overrides = append(overrides, matchedPath) + } + if d.IsDir() && matchedPath == relPath { + return filepath.SkipDir + } + break + } + return nil + }) + if err != nil { + return nil, err + } + sort.Strings(overrides) + return overrides, nil } diff --git a/src-rust/src/main.rs b/src-rust/src/main.rs index 3dddf9adf..8615ff5c4 100644 --- a/src-rust/src/main.rs +++ b/src-rust/src/main.rs @@ -1,6 +1,7 @@ use anyhow::{Context, Result, anyhow}; use clap::{Parser, Subcommand}; use serde::{Deserialize, Serialize}; +use std::collections::BTreeSet; use std::env; use std::fs; use std::io::{ErrorKind, Write}; @@ -143,6 +144,58 @@ fn parse_cross_overrides(content: &str) -> Vec { overrides } +fn glob_match(pattern: &str, value: &str) -> bool { + let pattern: Vec = pattern.chars().collect(); + let value: Vec = value.chars().collect(); + let mut pi = 0usize; + let mut vi = 0usize; + let mut star: Option = None; + let mut matched = 0usize; + + while vi < value.len() { + if pi < pattern.len() && (pattern[pi] == value[vi] || pattern[pi] == '?') { + pi += 1; + vi += 1; + } else if pi < pattern.len() && pattern[pi] == '*' { + star = Some(pi); + matched = vi; + pi += 1; + } else if let Some(star_idx) = star { + pi = star_idx + 1; + matched += 1; + vi = matched; + } else { + return false; + } + } + + while pi < pattern.len() && pattern[pi] == '*' { + pi += 1; + } + pi == pattern.len() +} + +fn match_cross_override(pattern: &str, rel_path: &str) -> Option { + let pattern = pattern.trim_end_matches('/'); + if pattern.is_empty() { + return None; + } + if pattern.contains('/') { + if rel_path == pattern || rel_path.starts_with(&format!("{}/", pattern)) { + return Some(pattern.to_string()); + } + return None; + } + + let parts: Vec<&str> = rel_path.split('/').collect(); + for idx in 0..parts.len() { + if glob_match(pattern, parts[idx]) { + return Some(parts[..=idx].join("/")); + } + } + None +} + fn get_cross_overrides(local_path: &Path) -> Result> { let path = local_path.join(".crossignore"); if !path.exists() { @@ -150,13 +203,53 @@ fn get_cross_overrides(local_path: &Path) -> Result> { } let content = fs::read_to_string(path)?; - Ok(parse_cross_overrides(&content)) + let patterns = parse_cross_overrides(&content); + if patterns.is_empty() { + return Ok(Vec::new()); + } + + let mut matches = BTreeSet::new(); + collect_cross_overrides(local_path, local_path, &patterns, &mut matches)?; + Ok(matches.into_iter().collect()) } fn has_cross_overrides(local_path: &Path) -> Result { Ok(!get_cross_overrides(local_path)?.is_empty()) } +fn collect_cross_overrides( + root: &Path, + current: &Path, + patterns: &[String], + matches: &mut BTreeSet, +) -> Result<()> { + for entry in fs::read_dir(current)? { + let entry = entry?; + let path = entry.path(); + let rel_path = path.strip_prefix(root)?.to_string_lossy().replace('\\', "/"); + if rel_path == ".git" || rel_path.starts_with(".git/") { + continue; + } + + let file_type = entry.file_type()?; + let mut matched_dir = false; + for pattern in patterns { + if let Some(matched_path) = match_cross_override(pattern, &rel_path) { + matches.insert(matched_path.clone()); + if file_type.is_dir() && matched_path == rel_path { + matched_dir = true; + } + break; + } + } + + if file_type.is_dir() && !matched_dir { + collect_cross_overrides(root, &path, patterns, matches)?; + } + } + Ok(()) +} + fn parse_patch_spec(spec: &str) -> Result { let parts: Vec<&str> = spec.split(':').collect(); if parts.len() < 2 { diff --git a/test/007_status.sh b/test/007_status.sh index 01189e638..ef7258f0c 100755 --- a/test/007_status.sh +++ b/test/007_status.sh @@ -46,11 +46,18 @@ check_status "vendor/docs" "Clean.*Synced" # ------------------------------------------------------------------ # Test 1b: override markers switch status to override review # ------------------------------------------------------------------ +mkdir -p vendor/docs/config +mkdir -p vendor/docs/subdir +touch vendor/docs/subdir/.env vendor/docs/config/private.yml cat > vendor/docs/.crossignore <<'EOF' .env +config/ EOF check_status "vendor/docs" "Override.*Synced" rm -f vendor/docs/.crossignore +rm -f vendor/docs/subdir/.env vendor/docs/config/private.yml +rmdir vendor/docs/subdir 2>/dev/null || true +rmdir vendor/docs/config 2>/dev/null || true # ------------------------------------------------------------------ # Test 2: Modified (Local change) diff --git a/test/008_rust_cli.sh b/test/008_rust_cli.sh index f21bee8d3..e3cc57540 100755 --- a/test/008_rust_cli.sh +++ b/test/008_rust_cli.sh @@ -138,6 +138,8 @@ fi echo "Updated logic" > vendor/rust-src/logic.rs log_header "Testing Rust '.crossignore' override status/diff hint..." +mkdir -p vendor/rust-src/subdir +touch vendor/rust-src/subdir/.env cat > vendor/rust-src/.crossignore <<'EOF' .env EOF @@ -153,10 +155,12 @@ 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" +if ! echo "$diff_output" | grep -q 'subdir/.env'; then + fail "Rust 'diff' should print basename override file command from nested directory" fi rm -f vendor/rust-src/.crossignore +rm -f vendor/rust-src/subdir/.env +rmdir vendor/rust-src/subdir 2>/dev/null || true log_header "Testing Rust 'push' command..." # Allow pushing to current branch in mock upstream diff --git a/test/009_go_cli.sh b/test/009_go_cli.sh index 34610947b..bfc283adb 100755 --- a/test/009_go_cli.sh +++ b/test/009_go_cli.sh @@ -184,6 +184,8 @@ fi echo "Updated go logic" > vendor/go-src/logic.go log_header "Testing Go '.crossignore' override status/diff hint..." +mkdir -p vendor/go-src/subdir +touch vendor/go-src/subdir/.env cat > vendor/go-src/.crossignore <<'EOF' .env EOF @@ -199,10 +201,12 @@ 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" +if ! echo "$diff_output" | grep -q 'subdir/.env'; then + fail "Go 'diff' should print basename override file command from nested directory" fi rm -f vendor/go-src/.crossignore +rm -f vendor/go-src/subdir/.env +rmdir vendor/go-src/subdir 2>/dev/null || true log_header "Testing Go 'push' command..." # Allow pushing to current branch in mock upstream diff --git a/test/016_diff_context.sh b/test/016_diff_context.sh index 904387fbc..59a98a9b0 100755 --- a/test/016_diff_context.sh +++ b/test/016_diff_context.sh @@ -120,15 +120,25 @@ fi # --- Test 8: override markers print manual diff commands --- log_header "Test 8: override markers print manual diff commands..." +mkdir -p vendor/alpha/config +mkdir -p vendor/alpha/subdir +touch vendor/alpha/subdir/.env vendor/alpha/config/private.yml cat > vendor/alpha/.crossignore <<'EOF' .env +config/ 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" +if ! echo "$output" | grep -q 'subdir/.env'; then + fail "Test 8: diff should print basename override file command from nested directory" +fi +if ! echo "$output" | grep -q 'config'; then + fail "Test 8: diff should print directory override command" fi rm -f vendor/alpha/.crossignore +rm -f vendor/alpha/subdir/.env vendor/alpha/config/private.yml +rmdir vendor/alpha/subdir 2>/dev/null || true +rmdir vendor/alpha/config 2>/dev/null || true echo "" echo "All context-aware diff tests passed!"