From 0c5f1807f05f806d81a41c6ccebe9f4ea54453c6 Mon Sep 17 00:00:00 2001 From: Test User Date: Thu, 28 May 2026 15:45:15 +0200 Subject: [PATCH] update cross overrides + tutorial --- CHANGELOG.md | 2 +- Justfile.cross | 48 +++++++- README.md | 7 ++ .../migrating-private-fork-to-git-cross.md | 48 ++++---- src-go/main.go | 105 +++++++++++++++++- src-rust/src/main.rs | 95 +++++++++++++++- test/007_status.sh | 7 ++ test/008_rust_cli.sh | 8 +- test/009_go_cli.sh | 8 +- test/016_diff_context.sh | 14 ++- 10 files changed, 312 insertions(+), 30 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index e5eaaca97..a014f7333 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -16,7 +16,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - **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. +- **`.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 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..352082058 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 diff --git a/docs/tutorials/migrating-private-fork-to-git-cross.md b/docs/tutorials/migrating-private-fork-to-git-cross.md index 3f8c3d1c9..39f8aaa92 100644 --- a/docs/tutorials/migrating-private-fork-to-git-cross.md +++ b/docs/tutorials/migrating-private-fork-to-git-cross.md @@ -63,7 +63,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,46 +75,57 @@ 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: +If you are using the Just implementation during migration, you can reuse the current `.crossignore` matches as the backup source list: ```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 +just cross _crossignore_overrides "$PWD" \ + | rsync -avR --files-from=- ./ ../private-overrides-backup/ ``` +If you are not using the Just implementation, use the same `.crossignore` file as your checklist and back up the matching files with `rsync`, `tar`, or your preferred tooling. + 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. @@ -150,10 +161,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 +169,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/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!"