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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 <path>` 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
Expand Down
48 changes: 47 additions & 1 deletion Justfile.cross
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand All @@ -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
Expand Down
7 changes: 7 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
48 changes: 28 additions & 20 deletions docs/tutorials/migrating-private-fork-to-git-cross.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand All @@ -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.
Expand Down Expand Up @@ -150,24 +161,21 @@ 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`:

```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

Expand Down
105 changes: 104 additions & 1 deletion src-go/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import (
"os"
"os/exec"
"path/filepath"
"sort"
"strings"

"github.com/fatih/color"
Expand Down Expand Up @@ -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 {
Expand All @@ -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
}


Expand Down
Loading
Loading