From 8de6a05c03ef7235c8fa439b20a683c8e4c1dc8c Mon Sep 17 00:00:00 2001
From: "p.michalec"
Date: Thu, 22 Jan 2026 07:47:36 +0100
Subject: [PATCH 1/2] feat: single file patch
---
CHANGELOG.md | 4 +-
Justfile | 1 +
Justfile.cross | 258 +++++++++++++++-------------
SESSION_SUMMARY.md | 8 +-
TODO.md | 29 +++-
test/018_single_file_patch.sh | 159 +++++++++++++++++
test/{015_prune.sh => 030_prune.sh} | 0
7 files changed, 336 insertions(+), 123 deletions(-)
create mode 100644 test/018_single_file_patch.sh
rename test/{015_prune.sh => 030_prune.sh} (100%)
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 94669ead6..24308412c 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
- Excludes 'origin' and 'git-cross' from cleanup
- Runs `git worktree prune` to clean stale worktrees
- Implemented across all three implementations (Just, Go, Rust)
- - Full test coverage in `test/015_prune.sh`
+ - Full test coverage in `test/030_prune.sh`
### Fixed
- **Sync command file deletion logic** - Only delete tracked files removed upstream
@@ -39,7 +39,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
### Testing
- Enhanced `test/004_sync.sh` with 6 comprehensive scenarios
-- Added `test/015_prune.sh` with 3 test scenarios
+- Added `test/030_prune.sh` with 3 test scenarios
- All tests pass for Just, Go, and Rust implementations
## [0.2.0] - 2025-12-01
diff --git a/Justfile b/Justfile
index 02d20bed1..aec711b05 100644
--- a/Justfile
+++ b/Justfile
@@ -3,6 +3,7 @@ import? "git.just"
# Delegate all invocations to the full recipe set
[no-cd]
@cross *ARGS:
+ echo "{{source_dir()}}/Justfile.cross"; \
REPO_DIR=$(git rev-parse --show-toplevel) \
just --justfile "{{source_dir()}}/Justfile.cross" {{ARGS}}
diff --git a/Justfile.cross b/Justfile.cross
index 61cc8708b..f53bdf19b 100644
--- a/Justfile.cross
+++ b/Justfile.cross
@@ -93,8 +93,8 @@ check-deps:
end
end
if test (count $missing) -gt 0
- just cross _log error "Missing: $missing"
- just cross _log info "Install with: brew install $missing"
+ just --justfile "{{justfile()}}" cross _log error "Missing: $missing"
+ just --justfile "{{justfile()}}" cross _log info "Install with: brew install $missing"
exit 1
end
@@ -110,7 +110,7 @@ _resolve_context2 path="": check-initialized
set -x path "$(git rev-parse --show-prefix | sed 's,\/$,,')" # cwd, relative to git repo
end
if test -z "$path"
- just cross _log error "Provide path to 'patch' or change directory into it."
+ just --justfile "{{justfile()}}" cross _log error "Provide path to 'patch' or change directory into it."
exit 1
end
# Query metadata.json and export matching key as env variables
@@ -159,10 +159,10 @@ exec +CMD:
init:
#!/usr/bin/env fish
if test -f "{{CROSSFILE}}"
- just cross _log info "Crossfile already exists."
+ just --justfile "{{justfile()}}" cross _log info "Crossfile already exists."
else
echo "# git-cross configuration" > "{{CROSSFILE}}"
- just cross _log success "Crossfile initialized."
+ just --justfile "{{justfile()}}" cross _log success "Crossfile initialized."
end
# AICONTEXT: "use" register remote git repository and update Crossfile with "use" command. Do not change implementation!
@@ -175,7 +175,7 @@ use name url: check-deps
git remote add {{name}} {{url}}
# Detect default branch
git ls-remote --heads {{url}} 2>/dev/null \
- && just cross update_crossfile "cross use {{name}} {{url}}"
+ && just --justfile "{{justfile()}}" cross update_crossfile "cross use {{name}} {{url}}"
end
# Remove a patch and its worktree
@@ -185,28 +185,28 @@ remove path: check-deps
set l_path "{{path}}"
pushd "{{REPO_DIR}}"
if not test -f {{METADATA}}
- just cross _log error "No metadata found."
+ just --justfile "{{justfile()}}" cross _log error "No metadata found."
exit 1
end
set entry (jq -r --arg lp "$l_path" '.patches[] | select(.local_path == $lp)' {{METADATA}})
if test -z "$entry"
- just cross _log error "Patch not found for path: $l_path"
+ just --justfile "{{justfile()}}" cross _log error "Patch not found for path: $l_path"
exit 1
end
set wt (echo "$entry" | jq -r '.worktree')
- just cross _log info "Removing patch at $l_path..."
+ just --justfile "{{justfile()}}" cross _log info "Removing patch at $l_path..."
# 1. Remove worktree
if test -d "$wt"
- just cross _log info "Removing git worktree at $wt..."
+ just --justfile "{{justfile()}}" cross _log info "Removing git worktree at $wt..."
git worktree remove --force "$wt"
end
# 2. Remove from Crossfile
- just cross _log info "Removing from Crossfile..."
+ just --justfile "{{justfile()}}" cross _log info "Removing from Crossfile..."
if test -f "{{CROSSFILE}}"
set tmp (mktemp)
grep -v "patch" "{{CROSSFILE}}" > "$tmp"
@@ -215,16 +215,16 @@ remove path: check-deps
end
# 3. Update metadata
- just cross _log info "Updating metadata..."
+ just --justfile "{{justfile()}}" cross _log info "Updating metadata..."
set tmp_meta (mktemp)
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..."
+ just --justfile "{{justfile()}}" cross _log info "Deleting local directory $l_path..."
rm -rf "$l_path"
- just cross _log success "Patch removed successfully."
+ just --justfile "{{justfile()}}" cross _log success "Patch removed successfully."
popd
# Prune unused remotes and worktrees, or remove all patches for a specific remote
@@ -233,37 +233,37 @@ prune remote_name="": check-deps
set remote "{{remote_name}}"
pushd "{{REPO_DIR}}"
if not test -f {{METADATA}}
- just cross _log error "No metadata found."
+ just --justfile "{{justfile()}}" cross _log error "No metadata found."
exit 1
end
if test -n "$remote"
# Prune specific remote: remove all its patches
- just cross _log info "Pruning all patches for remote: $remote..."
+ just --justfile "{{justfile()}}" cross _log info "Pruning all patches for remote: $remote..."
# Get all patches for this remote
set patches (jq -r --arg remote "$remote" '.patches[] | select(.remote == $remote) | .local_path' {{METADATA}})
if test -z "$patches"
- just cross _log warn "No patches found for remote: $remote"
+ just --justfile "{{justfile()}}" cross _log warn "No patches found for remote: $remote"
else
# Remove each patch
for patch_path in $patches
- just cross _log info "Removing patch: $patch_path"
- just cross remove "$patch_path"
+ just --justfile "{{justfile()}}" cross _log info "Removing patch: $patch_path"
+ just --justfile "{{justfile()}}" cross remove "$patch_path"
end
end
# Remove the remote itself
if git remote | grep -q "^$remote\$"
- just cross _log info "Removing git remote: $remote"
+ just --justfile "{{justfile()}}" cross _log info "Removing git remote: $remote"
git remote remove "$remote"
end
- just cross _log success "Remote $remote and all its patches pruned successfully."
+ just --justfile "{{justfile()}}" cross _log success "Remote $remote and all its patches pruned successfully."
else
# Prune all unused remotes (no active patches)
- just cross _log info "Finding unused remotes..."
+ just --justfile "{{justfile()}}" cross _log info "Finding unused remotes..."
# Get all remotes used by patches
set used_remotes (jq -r '.patches[].remote' {{METADATA}} | sort -u)
@@ -280,26 +280,26 @@ prune remote_name="": check-deps
end
if test -z "$unused_remotes"
- just cross _log info "No unused remotes found."
+ just --justfile "{{justfile()}}" cross _log info "No unused remotes found."
else
- just cross _log info "Unused remotes: $unused_remotes"
+ just --justfile "{{justfile()}}" cross _log info "Unused remotes: $unused_remotes"
read -P "Remove these remotes? [y/N]: " confirm
if test "$confirm" = "y"; or test "$confirm" = "Y"
for remote in $unused_remotes
- just cross _log info "Removing remote: $remote"
+ just --justfile "{{justfile()}}" cross _log info "Removing remote: $remote"
git remote remove "$remote"
end
- just cross _log success "Unused remotes removed."
+ just --justfile "{{justfile()}}" cross _log success "Unused remotes removed."
else
- just cross _log info "Pruning cancelled."
+ just --justfile "{{justfile()}}" cross _log info "Pruning cancelled."
end
end
# Always prune stale worktrees
- just cross _log info "Pruning stale worktrees..."
+ just --justfile "{{justfile()}}" cross _log info "Pruning stale worktrees..."
git worktree prune --verbose
- just cross _log success "Worktree pruning complete."
+ just --justfile "{{justfile()}}" cross _log success "Worktree pruning complete."
end
popd
@@ -318,27 +318,27 @@ patch remote_spec local_path="": check-deps
set remote $parts[1]
set remote_path $parts[2]
case '*'
- just cross _log error "Error: Invalid format Use remote[:branch]:remote_path [local_path]"
+ just --justfile "{{justfile()}}" cross _log error "Error: Invalid format Use remote[:branch]:remote_path [local_path]"
exit 1
end
- # update vars
- set r_path "$remote_path"
- set l_path "{{local_path}}"
- if test -z "$l_path"
- set l_path "$r_path"
- end
+ # update vars
+ set r_path "$remote_path"
+ set l_path "{{local_path}}"
+ if test -z "$l_path"
+ set l_path "$r_path"
+ end
pushd "{{REPO_DIR}}"
# validate remote
if not git remote show $remote |grep -vq "^$remote\$"
- just cross _log error "Error: Remote $remote not found. Run: just use $remote "
+ just --justfile "{{justfile()}}" 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]"
+ just --justfile "{{justfile()}}" cross _log error "Error: Invalid format Use remote[:branch]:remote_path [local_path]"
exit 1
end
@@ -350,34 +350,62 @@ patch remote_spec local_path="": check-deps
end
end
- # calculate hash/id
- set hash (echo $l_path | md5sum | cut -d' ' -f1 | cut -c1-8)
- set wt ".git/cross/worktrees/$remote"_"$hash"
-
+ # Determine worktree identity and remote path type
+ git fetch $remote $remote_branch >/dev/null 2>&1
+ set path_info (git ls-tree "$remote/$remote_branch" "$r_path")
+ if test -z "$path_info"
+ just --justfile "{{justfile()}}" cross _log error "Error: Remote path '$r_path' not found on $remote/$remote_branch"
+ exit 1
+ end
+
+ set remote_type "tree"
+ if string match -qr " blob " -- "$path_info"
+ set remote_type "file"
+ end
+
+ if test "$remote_type" = "file"
+ set wt_hash (echo "$remote:$remote_branch" | md5sum | cut -d' ' -f1 | cut -c1-8)
+ else
+ set wt_hash (echo $l_path | md5sum | cut -d' ' -f1 | cut -c1-8)
+ end
+ set patch_id (echo $l_path | md5sum | cut -d' ' -f1 | cut -c1-8)
+ set wt ".git/cross/worktrees/$remote"_"$wt_hash"
+
# setup worktree
- just cross _log info "Setting up worktree at $wt..."
+ just --justfile "{{justfile()}}" cross _log info "Setting up worktree at $wt..."
if not test -d $wt
mkdir -p (dirname $wt)
- git fetch $remote $remote_branch
- git worktree add --no-checkout -B "cross/$remote/$remote_branch/$hash" $wt "$remote/$remote_branch" >/dev/null 2>&1
+ git worktree add --no-checkout -B "cross/$remote/$remote_branch/$wt_hash" $wt "$remote/$remote_branch" >/dev/null 2>&1
# Sparse checkout
git -C $wt sparse-checkout init --no-cone
git -C $wt sparse-checkout set $r_path
git -C $wt checkout
+ git -C $wt reset --hard "$remote/$remote_branch" >/dev/null 2>&1
+ else
+ # Ensure sparse checkout includes desired path when reusing worktree
+ git -C $wt fetch $remote $remote_branch >/dev/null 2>&1
+ git -C $wt reset --hard "$remote/$remote_branch" >/dev/null 2>&1
+ git -C $wt sparse-checkout add $r_path >/dev/null 2>&1 || true
end
# sync to local_path
- just cross _log info "Syncing files to $l_path..."
- mkdir -p $l_path
- rsync -av --delete --exclude .git $wt/$r_path/ $l_path/
+ just --justfile "{{justfile()}}" cross _log info "Syncing files to $l_path..."
+ if test "$remote_type" = "file"
+ set local_dir (dirname $l_path)
+ mkdir -p $local_dir
+ rsync -av --exclude .git $wt/$r_path $l_path
+ else
+ mkdir -p $l_path
+ rsync -av --delete --exclude .git $wt/$r_path/ $l_path/
+ end
# Add local_path to git
git add $l_path
# update Crossfile
- just cross _log info "Update Crossfile"
- just cross update_crossfile "cross patch $remote:$remote_branch:$r_path $l_path"
+ just --justfile "{{justfile()}}" cross _log info "Update Crossfile"
+ just --justfile "{{justfile()}}" cross update_crossfile "cross patch $remote:$remote_branch:$r_path $l_path"
# Initialize metadata.json
if not test -f {{METADATA}}
@@ -385,12 +413,12 @@ patch remote_spec local_path="": check-deps
echo '{"patches": []}' > {{METADATA}}
end
# Update metadata.json
- set new_entry "{\"id\": \"$hash\", \"remote\": \"$remote\", \"remote_path\": \"$r_path\", \"local_path\": \"$l_path\", \"worktree\": \"$wt\", \"branch\": \"$remote_branch\"}"
+ set new_entry "{\"id\": \"$patch_id\", \"remote\": \"$remote\", \"remote_path\": \"$r_path\", \"local_path\": \"$l_path\", \"worktree\": \"$wt\", \"branch\": \"$remote_branch\"}"
# 1. Delete existing entry with same id (if any)
# 2. Append new entry
set tmp_file (mktemp)
## AICONTEXT: use direct update with jq instead the temp file
- jq ".patches |= map(select(.id != \"$hash\")) + [$new_entry]" "{{METADATA}}" > "$tmp_file"
+ jq ".patches |= map(select(.id != \"$patch_id\")) + [$new_entry]" "{{METADATA}}" > "$tmp_file"
mv "$tmp_file" "{{METADATA}}"
popd
@@ -398,7 +426,7 @@ patch remote_spec local_path="": check-deps
[no-cd]
@check-initialized:
cd {{REPO_DIR}} && test -d {{CROSSDIR}}/worktrees && test -f {{METADATA}} \
- || { just cross _log warn "No patches to sync"; exit 0; }
+ || { just --justfile "{{justfile()}}" cross _log warn "No patches to sync"; exit 0; }
# AICONTEXT: "sync" will sync all or the provided local_path with upstream. Workflow: 1. Stash uncommitted changes in local_path. 2. Rsync git-tracked files from local_path to worktree. 3. Commit changes in worktree. 4. Pull rebase from upstream. 5. If conflicts, exit and ask user to resolve. 6. Rsync worktree back to local_path. 7. Restore stashed changes. 8. Check for conflicts in restored changes.
# Sync all patches from upstream
@@ -406,11 +434,11 @@ patch remote_spec local_path="": check-deps
sync *path="": check-initialized
#!/usr/bin/env fish
# Query metadata.json
- just cross _resolve_context2 {{path}} | source \
- || { just cross _log error "Error: Could not resolve metadata for $path."; exit 1; }
+ just --justfile "{{justfile()}}" cross _resolve_context2 {{path}} | source \
+ || { just --justfile "{{justfile()}}" cross _log error "Error: Could not resolve metadata for $path."; exit 1; }
pushd "{{REPO_DIR}}"
- just cross _log info "Syncing $local_path with $worktree..."
+ just --justfile "{{justfile()}}" cross _log info "Syncing $local_path with $worktree..."
# 0. Ensure local_path exists
mkdir -p $local_path
@@ -419,13 +447,13 @@ sync *path="": check-initialized
set stashed false
set has_changes (git status --porcelain $local_path 2>/dev/null | wc -l | tr -d ' ')
if test "$has_changes" -gt 0
- just cross _log info "Detected uncommitted changes in $local_path..."
+ just --justfile "{{justfile()}}" cross _log info "Detected uncommitted changes in $local_path..."
set stashed true
end
# 1. Rsync current state (including uncommitted) from local_path to worktree
# AICONTEXT: the rsync need to sync only git tracked files in $local_path to $worktree/$remote_path
- just cross _log info "Syncing local changes to worktree..."
+ just --justfile "{{justfile()}}" cross _log info "Syncing local changes to worktree..."
if test -d $local_path
pushd $local_path
# Get tracked files (includes files with uncommitted changes)
@@ -439,7 +467,7 @@ sync *path="": check-initialized
# 1.5. NOW stash uncommitted changes (after copying them to worktree)
if test "$stashed" = "true"
- just cross _log info "Stashing uncommitted changes in $local_path..."
+ just --justfile "{{justfile()}}" cross _log info "Stashing uncommitted changes in $local_path..."
# Stash including untracked files, only in local_path
git stash push --include-untracked -m "cross-sync-auto-stash: $local_path" -- $local_path
end
@@ -448,7 +476,7 @@ sync *path="": check-initialized
# 2. Commit local changes in worktree
set dirty (git -C $worktree status --porcelain)
if test -n "$dirty"
- just cross _log info "Committing local changes in $worktree..."
+ just --justfile "{{justfile()}}" cross _log info "Committing local changes in $worktree..."
git -C $worktree add .
git -C $worktree commit -m "Sync local changes"
end
@@ -469,7 +497,7 @@ sync *path="": check-initialized
end
if test "$needs_cleanup" = "true"
- just cross _log warn "Worktree has an in-progress operation. Cleaning up..."
+ just --justfile "{{justfile()}}" cross _log warn "Worktree has an in-progress operation. Cleaning up..."
git rebase --abort 2>/dev/null || true
git merge --abort 2>/dev/null || true
# Force remove all possible rebase dirs
@@ -480,11 +508,11 @@ sync *path="": check-initialized
# Check for detached HEAD
if not git symbolic-ref -q HEAD >/dev/null 2>&1
- just cross _log warn "Worktree is in detached HEAD state. Attempting to recover..."
+ just --justfile "{{justfile()}}" cross _log warn "Worktree is in detached HEAD state. Attempting to recover..."
# Find the worktree's branch name
set branch_name (git for-each-ref --format='%(refname:short)' refs/heads/ | grep -E 'cross/' | head -1)
if test -n "$branch_name"
- just cross _log info "Checking out branch: $branch_name"
+ just --justfile "{{justfile()}}" cross _log info "Checking out branch: $branch_name"
git checkout -B $branch_name 2>/dev/null || true
# Reset to clean state
git fetch $remote 2>/dev/null || true
@@ -494,18 +522,18 @@ sync *path="": check-initialized
popd
# 3. Pull rebase from upstream
- just cross _log info "Pulling from upstream..."
+ just --justfile "{{justfile()}}" cross _log info "Pulling from upstream..."
if not git -C $worktree pull --rebase
- just cross _log error "Conflict detected in $worktree. Please resolve manually."
- just cross _log info "cd $worktree"
+ just --justfile "{{justfile()}}" cross _log error "Conflict detected in $worktree. Please resolve manually."
+ just --justfile "{{justfile()}}" cross _log info "cd $worktree"
if test "$stashed" = "true"
- just cross _log warn "Note: Local changes are stashed. Run 'git stash pop' in $local_path after resolving."
+ just --justfile "{{justfile()}}" cross _log warn "Note: Local changes are stashed. Run 'git stash pop' in $local_path after resolving."
end
exit 1
end
# 4. Sync back to local - ensure directory exists
- just cross _log info "Syncing back to $local_path..."
+ just --justfile "{{justfile()}}" cross _log info "Syncing back to $local_path..."
mkdir -p $local_path
# 4.1. Remove files from local_path that were deleted upstream
@@ -532,7 +560,7 @@ sync *path="": check-initialized
set rel_file (string replace -r "^$local_path/" "" $tracked_file)
# Check if this tracked file no longer exists in worktree
if not grep -qF "$rel_file" $temp_list 2>/dev/null
- just cross _log info "Removing deleted file: $rel_file"
+ just --justfile "{{justfile()}}" cross _log info "Removing deleted file: $rel_file"
rm -f {{REPO_DIR}}/$tracked_file
end
end
@@ -546,7 +574,7 @@ sync *path="": check-initialized
# 5. Restore stashed changes if they exist
if test "$stashed" = "true"
- just cross _log info "Restoring stashed local changes..."
+ just --justfile "{{justfile()}}" cross _log info "Restoring stashed local changes..."
# First, add any new files that came from worktree sync
git add $local_path 2>/dev/null || true
# Now try to pop the stash (might have conflicts if same files were modified upstream)
@@ -554,20 +582,20 @@ sync *path="": check-initialized
# Success - check for conflicts
set conflicts (git diff --name-only --diff-filter=U -- $local_path 2>/dev/null | wc -l | tr -d ' ')
if test "$conflicts" -gt 0
- just cross _log error "Conflicts detected after restoring local changes in $local_path:"
+ just --justfile "{{justfile()}}" cross _log error "Conflicts detected after restoring local changes in $local_path:"
git diff --name-only --diff-filter=U -- $local_path
- just cross _log info "Resolve conflicts, then run 'git add' and continue."
+ just --justfile "{{justfile()}}" cross _log info "Resolve conflicts, then run 'git add' and continue."
end
else
# Stash pop failed - likely due to conflicts
- just cross _log warn "Could not automatically restore stashed changes."
- just cross _log info "Your changes are preserved in the stash."
- just cross _log info "Run 'git stash list' to see them, 'git stash show' to view, 'git stash pop' to retry."
- just cross _log info "Or run 'git stash drop' if you want to discard them."
+ just --justfile "{{justfile()}}" cross _log warn "Could not automatically restore stashed changes."
+ just --justfile "{{justfile()}}" cross _log info "Your changes are preserved in the stash."
+ just --justfile "{{justfile()}}" cross _log info "Run 'git stash list' to see them, 'git stash show' to view, 'git stash pop' to retry."
+ just --justfile "{{justfile()}}" cross _log info "Or run 'git stash drop' if you want to discard them."
end
end
- just cross _log success "Sync completed for $local_path"
+ just --justfile "{{justfile()}}" cross _log success "Sync completed for $local_path"
popd
@@ -594,21 +622,21 @@ diff path="": check-initialized
set resolved_path (cd "$dir" && git rev-parse --show-prefix | sed 's,/$,,')
end
else
- just cross _log error "Error: Path does not exist: $resolved_path"
+ just --justfile "{{justfile()}}" cross _log error "Error: Path does not exist: $resolved_path"
exit 1
end
popd >/dev/null
end
# Query metadata.json
- just cross _resolve_context2 "$resolved_path" | source \
- || { just cross _log error "Error: Could not resolve metadata for '$resolved_path'."; exit 1; }
+ just --justfile "{{justfile()}}" cross _resolve_context2 "$resolved_path" | source \
+ || { just --justfile "{{justfile()}}" cross _log error "Error: Could not resolve metadata for '$resolved_path'."; exit 1; }
pushd "{{REPO_DIR}}"
if test -d $worktree
git diff --no-index $worktree/$remote_path $local_path || true
else
- just cross _log error "Error: Worktree not found $worktree"
+ just --justfile "{{justfile()}}" cross _log error "Error: Worktree not found $worktree"
exit 1
end
popd
@@ -620,23 +648,23 @@ push path="" branch="" force="false" yes="false" message="": check-initialized
#!/usr/bin/env fish
# Query metadata.json
- just cross _resolve_context2 "{{path}}" | source \
- || { just cross _log error "Error: Could not resolve metadata for '$path'."; exit 1; }
+ just --justfile "{{justfile()}}" cross _resolve_context2 "{{path}}" | source \
+ || { just --justfile "{{justfile()}}" cross _log error "Error: Could not resolve metadata for '$path'."; exit 1; }
pushd "{{REPO_DIR}}"
- just cross _log warn "The 'push' command is currently WORK IN PROGRESS."
+ just --justfile "{{justfile()}}" cross _log warn "The 'push' command is currently WORK IN PROGRESS."
if not test -d $worktree
- just cross _log error "Error: Worktree not found. Run 'just patch' first."
+ just --justfile "{{justfile()}}" cross _log error "Error: Worktree not found. Run 'just patch' first."
exit 1
end
- just cross _log info "Syncing changes from $local_path back to $worktree..."
+ just --justfile "{{justfile()}}" cross _log info "Syncing changes from $local_path back to $worktree..."
rsync -av --delete --exclude .git $local_path/ $worktree/$remote_path/
- just cross _log info "---------------------------------------------------"
- just cross _log info "Worktree updated. Status:"
+ just --justfile "{{justfile()}}" cross _log info "---------------------------------------------------"
+ just --justfile "{{justfile()}}" cross _log info "Worktree updated. Status:"
git -C $worktree status
- just cross _log info "---------------------------------------------------"
+ just --justfile "{{justfile()}}" cross _log info "---------------------------------------------------"
while true
if test "{{yes}}" = "true"
@@ -647,7 +675,7 @@ push path="" branch="" force="false" yes="false" message="": check-initialized
switch $choice
case r R
- just cross _log info "Preparing commit..."
+ just --justfile "{{justfile()}}" cross _log info "Preparing commit..."
pushd $worktree
git add .
@@ -669,7 +697,7 @@ push path="" branch="" force="false" yes="false" message="": check-initialized
if test -z "$msg"
set msg "Sync updates from $local_path"
end
- just cross _log info "Auto-generated message: $msg"
+ just --justfile "{{justfile()}}" cross _log info "Auto-generated message: $msg"
end
git commit -m "$msg"
@@ -689,22 +717,22 @@ push path="" branch="" force="false" yes="false" message="": check-initialized
set push_args $push_args "--force"
end
- just cross _log info "Pushing to $push_args..."
+ just --justfile "{{justfile()}}" cross _log info "Pushing to $push_args..."
git push $push_args
popd >/dev/null
break
case m M
- just cross _log info "Spawning subshell in $worktree..."
- just cross _log info "Type 'exit' to return."
+ just --justfile "{{justfile()}}" cross _log info "Spawning subshell in $worktree..."
+ just --justfile "{{justfile()}}" cross _log info "Type 'exit' to return."
pushd $worktree
fish
popd >/dev/null
- just cross _log info "Returned from manual mode."
+ just --justfile "{{justfile()}}" cross _log info "Returned from manual mode."
case c C
- just cross _log warn "Cancelled."
+ just --justfile "{{justfile()}}" cross _log warn "Cancelled."
exit 0
case '*'
- just cross _log error "Invalid choice."
+ just --justfile "{{justfile()}}" cross _log error "Invalid choice."
end
end
popd
@@ -720,7 +748,7 @@ list: check-deps
set used_remotes (jq -r '.patches[].remote' .git/cross/metadata.json | sort -u)
if test (count $used_remotes) -gt 0
- just cross _log info "Configured Remotes:"
+ just --justfile "{{justfile()}}" cross _log info "Configured Remotes:"
printf "%-20s %s\n" "NAME" "URL"
printf "%s\n" (string repeat -n 70 "-")
@@ -752,12 +780,12 @@ list: check-deps
end
if not test -f Crossfile
- just cross _log warn "No patches found (Crossfile missing)."
+ just --justfile "{{justfile()}}" cross _log warn "No patches found (Crossfile missing)."
popd >/dev/null
exit 0
end
- just cross _log info "Configured Patches:"
+ just --justfile "{{justfile()}}" cross _log info "Configured Patches:"
printf "%-20s %-30s %-20s\n" "REMOTE" "REMOTE PATH" "LOCAL PATH"
printf "%s\n" (string repeat -n 70 "-")
@@ -766,14 +794,14 @@ list: check-deps
printf "%-20s %-30s %-20s\n" $remote $rpath $lpath
end
else
- just cross _log info "No patches found. Run 'just cross patch ' to start."
+ just --justfile "{{justfile()}}" cross _log info "No patches found. Run 'just --justfile "{{justfile()}}" cross patch ' to start."
end
popd >/dev/null
# wt wrapper
[no-cd]
worktree path="":
- just cross cd "{{path}}" dry="{{dry}}"
+ just --justfile "{{justfile()}}" cross cd "{{path}}" dry="{{dry}}"
# Internal: Copy text to clipboard (cross-platform)
[no-cd]
@@ -799,7 +827,7 @@ _copy_to_clipboard text:
[no-cd]
_open_shell target path:
#!/usr/bin/env fish
- just cross _resolve_context2 "{{path}}" | source || exit 1
+ just --justfile "{{justfile()}}" cross _resolve_context2 "{{path}}" | source || exit 1
set -l dir (test "{{target}}" = "local_path" && echo $local_path || echo $worktree)
cd {{REPO_DIR}}/$dir && exec $SHELL
@@ -810,14 +838,14 @@ _open_shell target path:
wt path="":
#!/usr/bin/env fish
if test -n "{{path}}"
- just cross _open_shell worktree "{{path}}"
+ just --justfile "{{justfile()}}" cross _open_shell worktree "{{path}}"
else
- set -l selected (just cross list | tail -n +3 | fzf --height 40% 2>/dev/null | awk '{print $NF}')
+ set -l selected (just --justfile "{{justfile()}}" cross list | tail -n +3 | fzf --height 40% 2>/dev/null | awk '{print $NF}')
test -z "$selected" && exit 0
- just cross _resolve_context2 "$selected" | source || exit 1
+ just --justfile "{{justfile()}}" cross _resolve_context2 "$selected" | source || exit 1
set -l rel_dir (realpath -m --relative-to=$PWD {{REPO_DIR}}/$worktree)
- just cross _copy_to_clipboard $rel_dir
- just cross _log success "Path copied: $rel_dir"
+ just --justfile "{{justfile()}}" cross _copy_to_clipboard $rel_dir
+ just --justfile "{{justfile()}}" cross _log success "Path copied: $rel_dir"
end
# Go to local_path directory (for editing patched files)
@@ -827,14 +855,14 @@ wt path="":
cd path="":
#!/usr/bin/env fish
if test -n "{{path}}"
- just cross _open_shell local_path "{{path}}"
+ just --justfile "{{justfile()}}" cross _open_shell local_path "{{path}}"
else
- set -l selected (just cross list | tail -n +3 | fzf --height 40% 2>/dev/null | awk '{print $NF}')
+ set -l selected (just --justfile "{{justfile()}}" cross list | tail -n +3 | fzf --height 40% 2>/dev/null | awk '{print $NF}')
test -z "$selected" && exit 0
- just cross _resolve_context2 "$selected" | source || exit 1
+ just --justfile "{{justfile()}}" cross _resolve_context2 "$selected" | source || exit 1
set -l rel_dir (realpath -m --relative-to=$PWD {{REPO_DIR}}/$local_path)
- just cross _copy_to_clipboard $rel_dir
- just cross _log success "Path copied: $rel_dir"
+ just --justfile "{{justfile()}}" cross _copy_to_clipboard $rel_dir
+ just --justfile "{{justfile()}}" cross _log success "Path copied: $rel_dir"
end
@@ -844,7 +872,7 @@ cd path="":
status: check-deps
#!/usr/bin/env fish
if not test -f Crossfile
- just cross _log warn "No patches found."
+ just --justfile "{{justfile()}}" cross _log warn "No patches found."
exit 0
end
@@ -888,7 +916,7 @@ status: check-deps
printf "%-20s %-15s %-15s %-15s\n" $local_path $diff_stat $upstream_stat $conflict_stat
end
else
- just cross _log info "No patches found."
+ just --justfile "{{justfile()}}" cross _log info "No patches found."
end
# Replay commands from Crossfile
diff --git a/SESSION_SUMMARY.md b/SESSION_SUMMARY.md
index 188cdfbdb..1e8f8ca31 100644
--- a/SESSION_SUMMARY.md
+++ b/SESSION_SUMMARY.md
@@ -70,7 +70,7 @@ localPath := filepath.Join(root, p.LocalPath)
- Commands only exist in Go/Rust
- Test correctly skips for Justfile
-#### test/015_prune.sh ✅
+#### test/030_prune.sh ✅
**Fixed**: Complete rewrite to actually test prune functionality
- Test 1: Prune specific remote with patches
- Test 2: Setup validation for interactive prune
@@ -89,7 +89,7 @@ All modified tests now pass:
✅ test/007_status.sh - Status with conflict cleanup
✅ test/008_rust_cli.sh - Rust output handling
✅ test/010_worktree.sh - Correctly skips for Justfile
-✅ test/015_prune.sh - Complete prune functionality
+✅ test/030_prune.sh - Complete prune functionality
```
## Files Changed (Ready for Commit)
@@ -101,7 +101,7 @@ modified: test/003_diff.sh
modified: test/007_status.sh
modified: test/008_rust_cli.sh
modified: test/010_worktree.sh
-modified: test/015_prune.sh
+ modified: test/030_prune.sh
```
## Untracked Files (Can be ignored)
@@ -141,7 +141,7 @@ git commit -m "feat: Add relative path resolution for diff/status commands and f
- test/007: Fix stash conflict cleanup
- test/008: Handle Rust output format variations
- test/010: Skip for Justfile (cd/wt not implemented)
- - test/015: Complete rewrite with 3 prune test scenarios
+ - test/030: Complete rewrite with 3 prune test scenarios
All three implementations (Justfile, Go, Rust) now support:
- cd vendor/lib && git cross diff .
diff --git a/TODO.md b/TODO.md
index 43cb7a6b6..a9110566c 100644
--- a/TODO.md
+++ b/TODO.md
@@ -40,17 +40,19 @@
### P1: High Priority
- [x] **Implement `cross prune [remote name]`** - Remove git remote registration from "cross use" command and ask user whether to remove all git remotes without active cross patches (like after: cross remove), then `git worktree prune` to remove all worktrees. Optional argument (a remote repo alias/name) would enforce removal of all its patches together with worktrees and remotes.
- **Effort:** 3-4 hours (completed 2025-01-06)
- - **Files:** `src-go/main.go`, `src-rust/src/main.rs`, `Justfile.cross`, `test/015_prune.sh`
+ - **Files:** `src-go/main.go`, `src-rust/src/main.rs`, `Justfile.cross`, `test/030_prune.sh`
- **Implementation:**
- ✅ Justfile.cross (lines 230-303): Full interactive prune with confirmation
- ✅ Go (src-go/main.go): Cobra command with same logic
- ✅ Rust (src-rust/src/main.rs): Clap command with same logic
- - ✅ Test coverage (test/015_prune.sh): 3 test scenarios
+ - ✅ Test coverage (test/030_prune.sh): 3 test scenarios
- **Behavior:**
- `cross prune`: Finds unused remotes, asks for confirmation, removes them, prunes stale worktrees
- `cross prune `: Removes all patches for that remote, then removes the remote itself
- **Status:** COMPLETE - Ready for v0.2.1 release
+- [ ] Extend patch test. Create git repo. Create a new branch `featA`. Create a new independent git worktree in new working directory by `git worktree add $PWD-featA`; cd there; on this featA start testing `cross patch`.
+
### P2: Medium Priority
- [x] **Fix `cross cd` and `cross wt` commands** - Correct behavior for navigation
- **Issue:** Commands needed proper separation of concerns and clipboard functionality
@@ -137,6 +139,29 @@
- [x] **Issue:** The `cross sync` command in Go (and Rust) did not preserve local uncommitted changes. When users modified files in patched directory and ran sync, changes were lost/reverted.
+- [ ] Issue to patch some directories not exist. Crossfile not updated. .git/cross not created. This happened while working on non-main branch in independent git worktree (not a `cross worktree`).
+```
+ git cross patch this:f5cs-dnsFromXc-deploy-lib:ongoing/f5xc-tenants/ves-sre/f5cs-dns ongoing/f5xc-tenants/ves-sre/f5cs-dns
+ ==> Patching this:f5cs-dnsFromXc-deploy-lib:ongoing/f5xc-tenants/ves-sre/f5cs-dns to ongoing/f5xc-tenants/ves-sre/f5cs-dns
+ ==> Syncing files to ongoing/f5xc-tenants/ves-sre/f5cs-dns...
+ Error: rsync failed: exit status 23
+ Log: {rsync: [sender] change_dir "/Users/p.michalec/Work/gitlab-f5-xc/f5/volterra/ves.io/sre/xc-deploy-lib-release-ntt/.git/cross/worktrees/this_3aa5f4ff/ongoing/f5xc-tenants/ves-sre/f5cs-dns" failed: Not a directory (20)
+ rsync error: some files/attrs were not transferred (see previous errors) (code 23) at main.c(1358) [sender=3.4.1]
+ sending incremental file list
+
+ sent 19 bytes received 12 bytes 62.00 bytes/sec
+ total size is 0 speedup is 0.00
+ }
+ Usage:
+ git-cross patch [spec] [local_path] [flags]
+
+ Flags:
+ -h, --help help for patch
+
+ Global Flags:
+ --dry string Dry run command (e.g. echo)
+```
+
**Fix Applied (2025-01-06):**
- ✅ Go implementation (`src-go/main.go`): Added complete stash/restore workflow
- ✅ Rust implementation (`src-rust/src/main.rs`): Added complete stash/restore workflow
diff --git a/test/018_single_file_patch.sh b/test/018_single_file_patch.sh
new file mode 100644
index 000000000..f55fcd0ae
--- /dev/null
+++ b/test/018_single_file_patch.sh
@@ -0,0 +1,159 @@
+#!/usr/bin/env bash
+
+source "$(dirname "$0")/common.sh"
+
+set -euo pipefail
+
+REPO_ROOT=$(pwd)
+TEST_BASE=$(mktemp -d)
+
+GO_BIN="$REPO_ROOT/src-go/git-cross-go"
+RUST_BIN="$REPO_ROOT/src-rust/target/debug/git-cross-rust"
+
+build_go_binary() {
+ if [ ! -x "$GO_BIN" ]; then
+ log_info "Building Go binary at $GO_BIN"
+ (cd "$REPO_ROOT/src-go" && go build -o git-cross-go main.go)
+ fi
+}
+
+build_rust_binary() {
+ if [ ! -x "$RUST_BIN" ]; then
+ log_info "Building Rust binary at $RUST_BIN"
+ (cd "$REPO_ROOT/src-rust" && cargo build >/dev/null)
+ fi
+}
+
+prepare_upstream_file() {
+ local path="$1"
+ pushd "$path" >/dev/null
+ mkdir -p src/lib
+ echo "single file v1" > src/lib/lib.txt
+ git add src/lib/lib.txt
+ git commit -m "Add single file fixture" -q
+ popd >/dev/null
+}
+
+prepare_second_file() {
+ local path="$1"
+ local filename="$2"
+ local contents="$3"
+ pushd "$path" >/dev/null
+ mkdir -p src/lib
+ echo "$contents" > "src/lib/$filename"
+ git add "src/lib/$filename"
+ git commit -m "Add $filename" -q
+ popd >/dev/null
+}
+
+verify_file_absent() {
+ local label="$1"
+ local file_path="$2"
+ if [ -f "$file_path" ]; then
+ log_error "$label unexpectedly produced file at $file_path"
+ exit 1
+ fi
+ if [ -d "$file_path" ] && [ -f "$file_path/lib.txt" ]; then
+ log_error "$label unexpectedly produced lib.txt inside $file_path"
+ exit 1
+ fi
+ log_success "$label did not create single-file output (unsupported as expected)"
+}
+
+run_just_check() {
+ log_header "Justfile implementation - single file patch"
+ cd "$REPO_ROOT"
+ setup_sandbox "$TEST_BASE"
+ cd "$SANDBOX"
+
+ upstream_path=$(create_upstream "just-single")
+ prepare_upstream_file "$upstream_path"
+ upstream_url="file://$upstream_path"
+
+ just cross use repo1 "$upstream_url"
+ just cross patch repo1:src/lib/lib.txt vendor/just-single/lib.txt
+
+ if [ ! -f "vendor/just-single/lib.txt" ]; then
+ log_error "just cross patch failed to create file"
+ exit 1
+ fi
+ if ! grep -q "single file v1" "vendor/just-single/lib.txt"; then
+ log_error "just cross patch wrote unexpected content"
+ exit 1
+ fi
+ assert_grep "Crossfile" "cross patch repo1:main:src/lib/lib.txt vendor/just-single/lib.txt"
+ log_success "just cross patch successfully vendored single file"
+
+ prepare_second_file "$upstream_path" "lib2.txt" "single file v2"
+ just cross patch repo1:src/lib/lib2.txt vendor/just-single/lib2.txt
+ if [ ! -f "vendor/just-single/lib2.txt" ]; then
+ log_error "just cross patch failed to create second file"
+ exit 1
+ fi
+ if ! grep -q "single file v2" "vendor/just-single/lib2.txt"; then
+ log_error "just cross patch wrote unexpected content for second file"
+ exit 1
+ fi
+ assert_grep "Crossfile" "cross patch repo1:main:src/lib/lib2.txt vendor/just-single/lib2.txt"
+
+ wt_count=$(find .git/cross/worktrees -maxdepth 1 -type d -name 'repo1_*' | wc -l | tr -d ' ')
+ if [ "$wt_count" != "1" ]; then
+ log_error "Expected single shared worktree for repo1, found $wt_count"
+ exit 1
+ fi
+ log_success "just cross patch reused single worktree for multiple files"
+}
+
+run_go_check() {
+ log_header "Go CLI implementation - single file patch"
+ cd "$REPO_ROOT"
+ setup_sandbox "$TEST_BASE"
+ cd "$SANDBOX"
+
+ build_go_binary
+
+ upstream_path=$(create_upstream "go-single")
+ prepare_upstream_file "$upstream_path"
+ upstream_url="file://$upstream_path"
+
+ "$GO_BIN" use repo1 "$upstream_url"
+ "$GO_BIN" patch repo1:src/lib/lib.txt vendor/go-single/lib.txt || true
+
+ verify_file_absent "git-cross (Go) patch" "vendor/go-single/lib.txt"
+}
+
+run_rust_check() {
+ log_header "Rust CLI implementation - single file patch"
+ cd "$REPO_ROOT"
+ setup_sandbox "$TEST_BASE"
+ cd "$SANDBOX"
+
+ build_rust_binary
+
+ upstream_path=$(create_upstream "rust-single")
+ prepare_upstream_file "$upstream_path"
+ upstream_url="file://$upstream_path"
+
+ "$RUST_BIN" use repo1 "$upstream_url"
+ "$RUST_BIN" patch repo1:src/lib/lib.txt vendor/rust-single/lib.txt || true
+
+ verify_file_absent "git-cross-rust patch" "vendor/rust-single/lib.txt"
+
+ upstream_path2=$(create_upstream "rust-single-2")
+ prepare_upstream_file "$upstream_path2"
+ upstream_url2="file://$upstream_path2"
+
+ "$RUST_BIN" use repo2 "$upstream_url2"
+ "$RUST_BIN" patch repo2:src/lib/lib.txt vendor/rust-single/lib2.txt || true
+
+ verify_file_absent "git-cross-rust patch (second repo)" "vendor/rust-single/lib2.txt"
+}
+
+run_just_check
+run_go_check
+run_rust_check
+
+log_header "Summary"
+echo "Just implementation supports multi-file patch reuse; Go/Rust pending."
+
+rm -rf "$TEST_BASE"
diff --git a/test/015_prune.sh b/test/030_prune.sh
similarity index 100%
rename from test/015_prune.sh
rename to test/030_prune.sh
From f551594d1c86b18730f10b88b98bf70ea069f807 Mon Sep 17 00:00:00 2001
From: "p.michalec"
Date: Fri, 23 Jan 2026 07:44:31 +0100
Subject: [PATCH 2/2] new feature - to test
---
.github/CICD.md | 4 +-
AGENTS.md | 1 +
CHANGELOG.md | 20 +++
Crossfile | 1 +
Justfile | 10 +-
Justfile.cross | 324 +++++++++++++++++++++----------------
README.md | 9 +-
TODO.md | 10 +-
VERSION | 2 +-
src-go/main.go | 57 ++++++-
src-rust/Cargo.lock | 2 +-
src-rust/Cargo.toml | 2 +-
src-rust/src/main.rs | 238 ++++++++++++++++++---------
test/019_patch_worktree.sh | 232 ++++++++++++++++++++++++++
14 files changed, 678 insertions(+), 234 deletions(-)
create mode 100755 test/019_patch_worktree.sh
diff --git a/.github/CICD.md b/.github/CICD.md
index 10a95d5f7..61f064379 100644
--- a/.github/CICD.md
+++ b/.github/CICD.md
@@ -59,8 +59,8 @@ Added to README.md header:
### Creating a Release
```bash
# Tag and push
-git tag v0.2.1
-git push origin v0.2.1
+git tag v0.2.2
+git push origin v0.2.2
# GitHub Actions will automatically:
# 1. Create a release
diff --git a/AGENTS.md b/AGENTS.md
index a6e7b7e8e..1f29dde8c 100644
--- a/AGENTS.md
+++ b/AGENTS.md
@@ -52,6 +52,7 @@ Testing is modular and targets each implementation:
- **Bash/Fish**: `test/run-all.sh` executes legacy shell tests.
- **Rust**: `test/008_rust_cli.sh` verifies the Rust port.
- **Go**: `test/009_go_cli.sh` verifies the Go implementation.
+- **Integration Coverage**: Every new test must execute against all implementations (Justfile.cross, Go CLI, Rust CLI). Use the `just cross-test ` harness and mirror the multi-implementation pattern from tests like `test/019_patch_worktree.sh`.
For known issues and planned enhancements, see [TODO.md](TODO.md).
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 24308412c..931cf4258 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -7,6 +7,26 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
## [Unreleased]
+## [0.2.2] - 2026-01-22
+
+### Added
+- **Independent worktree support** across Just, Go, and Rust implementations
+ - Automatically resolves the shared `.git` directory when running from `git worktree` checkouts
+ - Honors `CROSSDIR` / `METADATA` environment overrides for automation and tests
+ - Synchronizes metadata and worktrees into the primary repository rather than the linked worktree
+- **Historical coverage** for worktree usage via `test/019_patch_worktree.sh`, executed for all implementations
+
+### Fixed
+- `git cross patch` (Go/Rust) now builds worktree paths from the shared git directory instead of assuming `.git/`
+- `Justfile.cross` sync, diff, and push targets normalize paths to absolute locations, eliminating rsync failures inside worktrees
+
+### Documentation
+- README highlights worktree support and environment overrides introduced in v0.2.2
+- Updated release instructions and agent docs to reference v0.2.2
+
+### Testing
+- Reworked `test/019_patch_worktree.sh` to provision branches safely, reuse helper utilities, and validate all three implementations from independent worktrees
+
## [0.2.1] - 2026-01-06
### Added
diff --git a/Crossfile b/Crossfile
index 2d436211e..1f82986b4 100644
--- a/Crossfile
+++ b/Crossfile
@@ -13,3 +13,4 @@ cross sync
cross use khue https://github.com/khuedoan/homelab
cross patch khue:master:/metal deploy/metal
+cross patch this:f5cs-dnsFromXc-deploy-lib:ongoing/f5xc-tenants/ves-sre/f5cs-dns ongoing/f5xc-tenants/ves-sre/f5cs-dns
diff --git a/Justfile b/Justfile
index aec711b05..b8c5d3e46 100644
--- a/Justfile
+++ b/Justfile
@@ -4,13 +4,19 @@ import? "git.just"
[no-cd]
@cross *ARGS:
echo "{{source_dir()}}/Justfile.cross"; \
- REPO_DIR=$(git rev-parse --show-toplevel) \
+ REPO_DIR=$(git rev-parse --show-toplevel); \
+ CROSSDIR="$(git rev-parse --path-format=absolute --git-common-dir)/cross"; \
+ METADATA="$CROSSDIR/metadata.json"; \
+ export REPO_DIR CROSSDIR METADATA; \
just --justfile "{{source_dir()}}/Justfile.cross" {{ARGS}}
# keep compatibility with `just test-cross`
[no-cd]
@cross-test *ARGS:
- REPO_DIR=$(git rev-parse --show-toplevel) \
+ REPO_DIR=$(git rev-parse --show-toplevel); \
+ CROSSDIR="$(git rev-parse --path-format=absolute --git-common-dir)/cross"; \
+ METADATA="$CROSSDIR/metadata.json"; \
+ export REPO_DIR CROSSDIR METADATA; \
just --justfile "{{source_dir()}}/Justfile.cross" test "{{ARGS}}"
# Run the Rust implementation
diff --git a/Justfile.cross b/Justfile.cross
index f53bdf19b..baf97cb8d 100644
--- a/Justfile.cross
+++ b/Justfile.cross
@@ -3,8 +3,8 @@ set export := true
set positional-arguments
CROSSFILE := "Crossfile"
-CROSSDIR := ".git/cross"
-METADATA := "$CROSSDIR/metadata.json"
+CROSSDIR := env("CROSSDIR", ".git/cross")
+METADATA := env("METADATA", "{{CROSSDIR}}/metadata.json")
JUST_DIR := env("JUST_DIR", source_dir())
REPO_DIR := env ("REPO_DIR", "$(git rev-parse --show-toplevel)")
SHELL := env("SHELL")
@@ -93,8 +93,8 @@ check-deps:
end
end
if test (count $missing) -gt 0
- just --justfile "{{justfile()}}" cross _log error "Missing: $missing"
- just --justfile "{{justfile()}}" cross _log info "Install with: brew install $missing"
+ just --justfile "{{justfile()}}" _log error "Missing: $missing"
+ just --justfile "{{justfile()}}" _log info "Install with: brew install $missing"
exit 1
end
@@ -110,18 +110,45 @@ _resolve_context2 path="": check-initialized
set -x path "$(git rev-parse --show-prefix | sed 's,\/$,,')" # cwd, relative to git repo
end
if test -z "$path"
- just --justfile "{{justfile()}}" cross _log error "Provide path to 'patch' or change directory into it."
+ just --justfile "{{justfile()}}" _log error "Provide path to 'patch' or change directory into it."
exit 1
end
# Query metadata.json and export matching key as env variables
# Find patch where local_path matches rel_target or is a parent of rel_target
+ set metadata (just --justfile "{{justfile()}}" _metadata_path)
jq -r --arg path "{{path}}" '
.patches
| map(. as $patch | select($patch.local_path | startswith($path)))
| map(. + {mlen:(.local_path|length)})
| max_by(.mlen)
| to_entries | map("set -x \(.key) \(.value|@sh)") | .[]
- ' "{{REPO_DIR}}/{{METADATA}}"
+ ' "$metadata"
+
+[no-cd]
+_resolve_crossdir:
+ #!/usr/bin/env fish
+ set crossdir "{{CROSSDIR}}"
+ if string match -q '/*' -- "$crossdir"
+ echo $crossdir
+ else
+ echo {{REPO_DIR}}/$crossdir
+ end
+
+[no-cd]
+_metadata_path:
+ #!/usr/bin/env fish
+ set crossdir (just --justfile "{{justfile()}}" _resolve_crossdir)
+ echo "$crossdir/metadata.json"
+
+[no-cd]
+_resolve_patch_path path:
+ #!/usr/bin/env fish
+ set p "{{path}}"
+ if string match -q '/*' -- "$p"
+ echo "$p"
+ else
+ echo {{REPO_DIR}}/$p
+ end
# AICONTEXT: This method updates Crossfile, an configuration file for git-cross. Do not change implementation!
# Internal: append command to Crossfile (avoiding duplicates)
@@ -159,10 +186,10 @@ exec +CMD:
init:
#!/usr/bin/env fish
if test -f "{{CROSSFILE}}"
- just --justfile "{{justfile()}}" cross _log info "Crossfile already exists."
+ just --justfile "{{justfile()}}" _log info "Crossfile already exists."
else
echo "# git-cross configuration" > "{{CROSSFILE}}"
- just --justfile "{{justfile()}}" cross _log success "Crossfile initialized."
+ just --justfile "{{justfile()}}" _log success "Crossfile initialized."
end
# AICONTEXT: "use" register remote git repository and update Crossfile with "use" command. Do not change implementation!
@@ -175,7 +202,7 @@ use name url: check-deps
git remote add {{name}} {{url}}
# Detect default branch
git ls-remote --heads {{url}} 2>/dev/null \
- && just --justfile "{{justfile()}}" cross update_crossfile "cross use {{name}} {{url}}"
+ && just --justfile "{{justfile()}}" update_crossfile "cross use {{name}} {{url}}"
end
# Remove a patch and its worktree
@@ -185,28 +212,28 @@ remove path: check-deps
set l_path "{{path}}"
pushd "{{REPO_DIR}}"
if not test -f {{METADATA}}
- just --justfile "{{justfile()}}" cross _log error "No metadata found."
+ just --justfile "{{justfile()}}" _log error "No metadata found."
exit 1
end
set entry (jq -r --arg lp "$l_path" '.patches[] | select(.local_path == $lp)' {{METADATA}})
if test -z "$entry"
- just --justfile "{{justfile()}}" cross _log error "Patch not found for path: $l_path"
+ just --justfile "{{justfile()}}" _log error "Patch not found for path: $l_path"
exit 1
end
set wt (echo "$entry" | jq -r '.worktree')
- just --justfile "{{justfile()}}" cross _log info "Removing patch at $l_path..."
+ just --justfile "{{justfile()}}" _log info "Removing patch at $l_path..."
# 1. Remove worktree
if test -d "$wt"
- just --justfile "{{justfile()}}" cross _log info "Removing git worktree at $wt..."
+ just --justfile "{{justfile()}}" _log info "Removing git worktree at $wt..."
git worktree remove --force "$wt"
end
# 2. Remove from Crossfile
- just --justfile "{{justfile()}}" cross _log info "Removing from Crossfile..."
+ just --justfile "{{justfile()}}" _log info "Removing from Crossfile..."
if test -f "{{CROSSFILE}}"
set tmp (mktemp)
grep -v "patch" "{{CROSSFILE}}" > "$tmp"
@@ -215,16 +242,16 @@ remove path: check-deps
end
# 3. Update metadata
- just --justfile "{{justfile()}}" cross _log info "Updating metadata..."
+ just --justfile "{{justfile()}}" _log info "Updating metadata..."
set tmp_meta (mktemp)
jq --arg lp "$l_path" '.patches |= map(select(.local_path != $lp))' {{METADATA}} > "$tmp_meta"
mv "$tmp_meta" {{METADATA}}
# 4. Remove local directory
- just --justfile "{{justfile()}}" cross _log info "Deleting local directory $l_path..."
+ just --justfile "{{justfile()}}" _log info "Deleting local directory $l_path..."
rm -rf "$l_path"
- just --justfile "{{justfile()}}" cross _log success "Patch removed successfully."
+ just --justfile "{{justfile()}}" _log success "Patch removed successfully."
popd
# Prune unused remotes and worktrees, or remove all patches for a specific remote
@@ -233,37 +260,37 @@ prune remote_name="": check-deps
set remote "{{remote_name}}"
pushd "{{REPO_DIR}}"
if not test -f {{METADATA}}
- just --justfile "{{justfile()}}" cross _log error "No metadata found."
+ just --justfile "{{justfile()}}" _log error "No metadata found."
exit 1
end
if test -n "$remote"
# Prune specific remote: remove all its patches
- just --justfile "{{justfile()}}" cross _log info "Pruning all patches for remote: $remote..."
+ just --justfile "{{justfile()}}" _log info "Pruning all patches for remote: $remote..."
# Get all patches for this remote
set patches (jq -r --arg remote "$remote" '.patches[] | select(.remote == $remote) | .local_path' {{METADATA}})
if test -z "$patches"
- just --justfile "{{justfile()}}" cross _log warn "No patches found for remote: $remote"
+ just --justfile "{{justfile()}}" _log warn "No patches found for remote: $remote"
else
# Remove each patch
for patch_path in $patches
- just --justfile "{{justfile()}}" cross _log info "Removing patch: $patch_path"
- just --justfile "{{justfile()}}" cross remove "$patch_path"
+ just --justfile "{{justfile()}}" _log info "Removing patch: $patch_path"
+ just --justfile "{{justfile()}}" remove "$patch_path"
end
end
# Remove the remote itself
if git remote | grep -q "^$remote\$"
- just --justfile "{{justfile()}}" cross _log info "Removing git remote: $remote"
+ just --justfile "{{justfile()}}" _log info "Removing git remote: $remote"
git remote remove "$remote"
end
- just --justfile "{{justfile()}}" cross _log success "Remote $remote and all its patches pruned successfully."
+ just --justfile "{{justfile()}}" _log success "Remote $remote and all its patches pruned successfully."
else
# Prune all unused remotes (no active patches)
- just --justfile "{{justfile()}}" cross _log info "Finding unused remotes..."
+ just --justfile "{{justfile()}}" _log info "Finding unused remotes..."
# Get all remotes used by patches
set used_remotes (jq -r '.patches[].remote' {{METADATA}} | sort -u)
@@ -280,26 +307,26 @@ prune remote_name="": check-deps
end
if test -z "$unused_remotes"
- just --justfile "{{justfile()}}" cross _log info "No unused remotes found."
+ just --justfile "{{justfile()}}" _log info "No unused remotes found."
else
- just --justfile "{{justfile()}}" cross _log info "Unused remotes: $unused_remotes"
+ just --justfile "{{justfile()}}" _log info "Unused remotes: $unused_remotes"
read -P "Remove these remotes? [y/N]: " confirm
if test "$confirm" = "y"; or test "$confirm" = "Y"
for remote in $unused_remotes
- just --justfile "{{justfile()}}" cross _log info "Removing remote: $remote"
+ just --justfile "{{justfile()}}" _log info "Removing remote: $remote"
git remote remove "$remote"
end
- just --justfile "{{justfile()}}" cross _log success "Unused remotes removed."
+ just --justfile "{{justfile()}}" _log success "Unused remotes removed."
else
- just --justfile "{{justfile()}}" cross _log info "Pruning cancelled."
+ just --justfile "{{justfile()}}" _log info "Pruning cancelled."
end
end
# Always prune stale worktrees
- just --justfile "{{justfile()}}" cross _log info "Pruning stale worktrees..."
+ just --justfile "{{justfile()}}" _log info "Pruning stale worktrees..."
git worktree prune --verbose
- just --justfile "{{justfile()}}" cross _log success "Worktree pruning complete."
+ just --justfile "{{justfile()}}" _log success "Worktree pruning complete."
end
popd
@@ -318,7 +345,7 @@ patch remote_spec local_path="": check-deps
set remote $parts[1]
set remote_path $parts[2]
case '*'
- just --justfile "{{justfile()}}" cross _log error "Error: Invalid format Use remote[:branch]:remote_path [local_path]"
+ just --justfile "{{justfile()}}" _log error "Error: Invalid format Use remote[:branch]:remote_path [local_path]"
exit 1
end
@@ -333,12 +360,12 @@ patch remote_spec local_path="": check-deps
# validate remote
if not git remote show $remote |grep -vq "^$remote\$"
- just --justfile "{{justfile()}}" cross _log error "Error: Remote $remote not found. Run: just use $remote "
+ just --justfile "{{justfile()}}" _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 --justfile "{{justfile()}}" cross _log error "Error: Invalid format Use remote[:branch]:remote_path [local_path]"
+ just --justfile "{{justfile()}}" _log error "Error: Invalid format Use remote[:branch]:remote_path [local_path]"
exit 1
end
@@ -354,7 +381,7 @@ patch remote_spec local_path="": check-deps
git fetch $remote $remote_branch >/dev/null 2>&1
set path_info (git ls-tree "$remote/$remote_branch" "$r_path")
if test -z "$path_info"
- just --justfile "{{justfile()}}" cross _log error "Error: Remote path '$r_path' not found on $remote/$remote_branch"
+ just --justfile "{{justfile()}}" _log error "Error: Remote path '$r_path' not found on $remote/$remote_branch"
exit 1
end
@@ -369,10 +396,10 @@ patch remote_spec local_path="": check-deps
set wt_hash (echo $l_path | md5sum | cut -d' ' -f1 | cut -c1-8)
end
set patch_id (echo $l_path | md5sum | cut -d' ' -f1 | cut -c1-8)
- set wt ".git/cross/worktrees/$remote"_"$wt_hash"
+ set wt "{{CROSSDIR}}/worktrees/$remote"_"$wt_hash"
# setup worktree
- just --justfile "{{justfile()}}" cross _log info "Setting up worktree at $wt..."
+ just --justfile "{{justfile()}}" _log info "Setting up worktree at $wt..."
if not test -d $wt
mkdir -p (dirname $wt)
git worktree add --no-checkout -B "cross/$remote/$remote_branch/$wt_hash" $wt "$remote/$remote_branch" >/dev/null 2>&1
@@ -390,7 +417,7 @@ patch remote_spec local_path="": check-deps
end
# sync to local_path
- just --justfile "{{justfile()}}" cross _log info "Syncing files to $l_path..."
+ just --justfile "{{justfile()}}" _log info "Syncing files to $l_path..."
if test "$remote_type" = "file"
set local_dir (dirname $l_path)
mkdir -p $local_dir
@@ -404,8 +431,8 @@ patch remote_spec local_path="": check-deps
git add $l_path
# update Crossfile
- just --justfile "{{justfile()}}" cross _log info "Update Crossfile"
- just --justfile "{{justfile()}}" cross update_crossfile "cross patch $remote:$remote_branch:$r_path $l_path"
+ just --justfile "{{justfile()}}" _log info "Update Crossfile"
+ just --justfile "{{justfile()}}" update_crossfile "cross patch $remote:$remote_branch:$r_path $l_path"
# Initialize metadata.json
if not test -f {{METADATA}}
@@ -425,8 +452,11 @@ patch remote_spec local_path="": check-deps
[no-cd]
@check-initialized:
- cd {{REPO_DIR}} && test -d {{CROSSDIR}}/worktrees && test -f {{METADATA}} \
- || { just --justfile "{{justfile()}}" cross _log warn "No patches to sync"; exit 0; }
+ #!/usr/bin/env fish
+ set -l crossdir (just --justfile "{{justfile()}}" _resolve_crossdir)
+ set -l metadata (just --justfile "{{justfile()}}" _metadata_path)
+ test -d $crossdir/worktrees && test -f $metadata \
+ || { just --justfile "{{justfile()}}" _log warn "No patches to sync"; exit 0; }
# AICONTEXT: "sync" will sync all or the provided local_path with upstream. Workflow: 1. Stash uncommitted changes in local_path. 2. Rsync git-tracked files from local_path to worktree. 3. Commit changes in worktree. 4. Pull rebase from upstream. 5. If conflicts, exit and ask user to resolve. 6. Rsync worktree back to local_path. 7. Restore stashed changes. 8. Check for conflicts in restored changes.
# Sync all patches from upstream
@@ -434,11 +464,14 @@ patch remote_spec local_path="": check-deps
sync *path="": check-initialized
#!/usr/bin/env fish
# Query metadata.json
- just --justfile "{{justfile()}}" cross _resolve_context2 {{path}} | source \
- || { just --justfile "{{justfile()}}" cross _log error "Error: Could not resolve metadata for $path."; exit 1; }
+ just --justfile "{{justfile()}}" _resolve_context2 {{path}} | source \
+ || { just --justfile "{{justfile()}}" _log error "Error: Could not resolve metadata for $path."; exit 1; }
pushd "{{REPO_DIR}}"
- just --justfile "{{justfile()}}" cross _log info "Syncing $local_path with $worktree..."
+ set -l worktree_abs (just --justfile "{{justfile()}}" _resolve_patch_path "$worktree")
+ set -l local_abs (just --justfile "{{justfile()}}" _resolve_patch_path "$local_path")
+ set -l git_common (string replace -r '/cross/?$' '' {{CROSSDIR}})
+ just --justfile "{{justfile()}}" _log info "Syncing $local_path with $worktree..."
# 0. Ensure local_path exists
mkdir -p $local_path
@@ -447,46 +480,46 @@ sync *path="": check-initialized
set stashed false
set has_changes (git status --porcelain $local_path 2>/dev/null | wc -l | tr -d ' ')
if test "$has_changes" -gt 0
- just --justfile "{{justfile()}}" cross _log info "Detected uncommitted changes in $local_path..."
+ just --justfile "{{justfile()}}" _log info "Detected uncommitted changes in $local_path..."
set stashed true
end
# 1. Rsync current state (including uncommitted) from local_path to worktree
# AICONTEXT: the rsync need to sync only git tracked files in $local_path to $worktree/$remote_path
- just --justfile "{{justfile()}}" cross _log info "Syncing local changes to worktree..."
+ just --justfile "{{justfile()}}" _log info "Syncing local changes to worktree..."
if test -d $local_path
pushd $local_path
# Get tracked files (includes files with uncommitted changes)
set tracked (git ls-files .)
if test -n "$tracked"
# Rsync tracked files (with their current content, including uncommitted changes)
- git ls-files . -z | rsync -0 --files-from=- -av --relative --exclude .git {{REPO_DIR}}/$local_path {{REPO_DIR}}/$worktree/$remote_path
+ git ls-files . -z | rsync -0 --files-from=- -av --relative --exclude .git $local_abs $worktree_abs/$remote_path
end
popd
end
# 1.5. NOW stash uncommitted changes (after copying them to worktree)
if test "$stashed" = "true"
- just --justfile "{{justfile()}}" cross _log info "Stashing uncommitted changes in $local_path..."
+ just --justfile "{{justfile()}}" _log info "Stashing uncommitted changes in $local_path..."
# Stash including untracked files, only in local_path
git stash push --include-untracked -m "cross-sync-auto-stash: $local_path" -- $local_path
end
# 2. Commit local changes in worktree
- set dirty (git -C $worktree status --porcelain)
+ set dirty (git -C $worktree_abs status --porcelain)
if test -n "$dirty"
- just --justfile "{{justfile()}}" cross _log info "Committing local changes in $worktree..."
- git -C $worktree add .
- git -C $worktree commit -m "Sync local changes"
+ just --justfile "{{justfile()}}" _log info "Committing local changes in $worktree..."
+ git -C $worktree_abs add .
+ git -C $worktree_abs commit -m "Sync local changes"
end
# 2.5. Check if worktree is in a good state (not detached HEAD, not mid-rebase)
- pushd $worktree
+ pushd $worktree_abs
# Check for ongoing rebase/merge FIRST (before anything else)
# For worktrees, rebase-merge can be in multiple locations
- set worktree_name (basename $worktree)
- set rebase_dirs .git/rebase-merge .git/rebase-apply {{REPO_DIR}}/.git/worktrees/$worktree_name/rebase-merge {{REPO_DIR}}/.git/worktrees/$worktree_name/rebase-apply
+ set worktree_name (basename $worktree_abs)
+ set rebase_dirs .git/rebase-merge .git/rebase-apply $git_common/worktrees/$worktree_name/rebase-merge $git_common/worktrees/$worktree_name/rebase-apply
set needs_cleanup false
for dir in $rebase_dirs
@@ -497,7 +530,7 @@ sync *path="": check-initialized
end
if test "$needs_cleanup" = "true"
- just --justfile "{{justfile()}}" cross _log warn "Worktree has an in-progress operation. Cleaning up..."
+ just --justfile "{{justfile()}}" _log warn "Worktree has an in-progress operation. Cleaning up..."
git rebase --abort 2>/dev/null || true
git merge --abort 2>/dev/null || true
# Force remove all possible rebase dirs
@@ -508,11 +541,11 @@ sync *path="": check-initialized
# Check for detached HEAD
if not git symbolic-ref -q HEAD >/dev/null 2>&1
- just --justfile "{{justfile()}}" cross _log warn "Worktree is in detached HEAD state. Attempting to recover..."
+ just --justfile "{{justfile()}}" _log warn "Worktree is in detached HEAD state. Attempting to recover..."
# Find the worktree's branch name
set branch_name (git for-each-ref --format='%(refname:short)' refs/heads/ | grep -E 'cross/' | head -1)
if test -n "$branch_name"
- just --justfile "{{justfile()}}" cross _log info "Checking out branch: $branch_name"
+ just --justfile "{{justfile()}}" _log info "Checking out branch: $branch_name"
git checkout -B $branch_name 2>/dev/null || true
# Reset to clean state
git fetch $remote 2>/dev/null || true
@@ -522,25 +555,25 @@ sync *path="": check-initialized
popd
# 3. Pull rebase from upstream
- just --justfile "{{justfile()}}" cross _log info "Pulling from upstream..."
- if not git -C $worktree pull --rebase
- just --justfile "{{justfile()}}" cross _log error "Conflict detected in $worktree. Please resolve manually."
- just --justfile "{{justfile()}}" cross _log info "cd $worktree"
+ just --justfile "{{justfile()}}" _log info "Pulling from upstream..."
+ if not git -C $worktree_abs pull --rebase
+ just --justfile "{{justfile()}}" _log error "Conflict detected in $worktree. Please resolve manually."
+ just --justfile "{{justfile()}}" _log info "cd $worktree_abs"
if test "$stashed" = "true"
- just --justfile "{{justfile()}}" cross _log warn "Note: Local changes are stashed. Run 'git stash pop' in $local_path after resolving."
+ just --justfile "{{justfile()}}" _log warn "Note: Local changes are stashed. Run 'git stash pop' in $local_path after resolving."
end
exit 1
end
# 4. Sync back to local - ensure directory exists
- just --justfile "{{justfile()}}" cross _log info "Syncing back to $local_path..."
+ just --justfile "{{justfile()}}" _log info "Syncing back to $local_path..."
mkdir -p $local_path
# 4.1. Remove files from local_path that were deleted upstream
# Only delete files that are:
# - Tracked in the main repo (in local_path)
# - No longer exist in the worktree
- pushd $worktree/$remote_path
+ pushd $worktree_abs/$remote_path
# Get list of tracked files in worktree
set worktree_files (git ls-files .)
@@ -560,7 +593,7 @@ sync *path="": check-initialized
set rel_file (string replace -r "^$local_path/" "" $tracked_file)
# Check if this tracked file no longer exists in worktree
if not grep -qF "$rel_file" $temp_list 2>/dev/null
- just --justfile "{{justfile()}}" cross _log info "Removing deleted file: $rel_file"
+ just --justfile "{{justfile()}}" _log info "Removing deleted file: $rel_file"
rm -f {{REPO_DIR}}/$tracked_file
end
end
@@ -569,12 +602,12 @@ sync *path="": check-initialized
rm -f $temp_list
# 4.2. Rsync tracked files from worktree to local
- git ls-files . -z | rsync -0 --files-from=- -av --relative --exclude .git {{REPO_DIR}}/$worktree/$remote_path {{REPO_DIR}}/$local_path
+ git ls-files . -z | rsync -0 --files-from=- -av --relative --exclude .git $worktree_abs/$remote_path $local_abs
popd
# 5. Restore stashed changes if they exist
if test "$stashed" = "true"
- just --justfile "{{justfile()}}" cross _log info "Restoring stashed local changes..."
+ just --justfile "{{justfile()}}" _log info "Restoring stashed local changes..."
# First, add any new files that came from worktree sync
git add $local_path 2>/dev/null || true
# Now try to pop the stash (might have conflicts if same files were modified upstream)
@@ -582,20 +615,20 @@ sync *path="": check-initialized
# Success - check for conflicts
set conflicts (git diff --name-only --diff-filter=U -- $local_path 2>/dev/null | wc -l | tr -d ' ')
if test "$conflicts" -gt 0
- just --justfile "{{justfile()}}" cross _log error "Conflicts detected after restoring local changes in $local_path:"
+ just --justfile "{{justfile()}}" _log error "Conflicts detected after restoring local changes in $local_path:"
git diff --name-only --diff-filter=U -- $local_path
- just --justfile "{{justfile()}}" cross _log info "Resolve conflicts, then run 'git add' and continue."
+ just --justfile "{{justfile()}}" _log info "Resolve conflicts, then run 'git add' and continue."
end
else
# Stash pop failed - likely due to conflicts
- just --justfile "{{justfile()}}" cross _log warn "Could not automatically restore stashed changes."
- just --justfile "{{justfile()}}" cross _log info "Your changes are preserved in the stash."
- just --justfile "{{justfile()}}" cross _log info "Run 'git stash list' to see them, 'git stash show' to view, 'git stash pop' to retry."
- just --justfile "{{justfile()}}" cross _log info "Or run 'git stash drop' if you want to discard them."
+ just --justfile "{{justfile()}}" _log warn "Could not automatically restore stashed changes."
+ just --justfile "{{justfile()}}" _log info "Your changes are preserved in the stash."
+ just --justfile "{{justfile()}}" _log info "Run 'git stash list' to see them, 'git stash show' to view, 'git stash pop' to retry."
+ just --justfile "{{justfile()}}" _log info "Or run 'git stash drop' if you want to discard them."
end
end
- just --justfile "{{justfile()}}" cross _log success "Sync completed for $local_path"
+ just --justfile "{{justfile()}}" _log success "Sync completed for $local_path"
popd
@@ -622,21 +655,23 @@ diff path="": check-initialized
set resolved_path (cd "$dir" && git rev-parse --show-prefix | sed 's,/$,,')
end
else
- just --justfile "{{justfile()}}" cross _log error "Error: Path does not exist: $resolved_path"
+ just --justfile "{{justfile()}}" _log error "Error: Path does not exist: $resolved_path"
exit 1
end
popd >/dev/null
end
# Query metadata.json
- just --justfile "{{justfile()}}" cross _resolve_context2 "$resolved_path" | source \
- || { just --justfile "{{justfile()}}" cross _log error "Error: Could not resolve metadata for '$resolved_path'."; exit 1; }
+ just --justfile "{{justfile()}}" _resolve_context2 "$resolved_path" | source \
+ || { just --justfile "{{justfile()}}" _log error "Error: Could not resolve metadata for '$resolved_path'."; exit 1; }
pushd "{{REPO_DIR}}"
- if test -d $worktree
- git diff --no-index $worktree/$remote_path $local_path || true
+ set -l wt_path (just --justfile "{{justfile()}}" _resolve_patch_path "$worktree")
+ set -l local_abs (just --justfile "{{justfile()}}" _resolve_patch_path "$local_path")
+ if test -d $wt_path
+ git diff --no-index $wt_path/$remote_path $local_abs || true
else
- just --justfile "{{justfile()}}" cross _log error "Error: Worktree not found $worktree"
+ just --justfile "{{justfile()}}" _log error "Error: Worktree not found $worktree"
exit 1
end
popd
@@ -648,23 +683,25 @@ push path="" branch="" force="false" yes="false" message="": check-initialized
#!/usr/bin/env fish
# Query metadata.json
- just --justfile "{{justfile()}}" cross _resolve_context2 "{{path}}" | source \
- || { just --justfile "{{justfile()}}" cross _log error "Error: Could not resolve metadata for '$path'."; exit 1; }
+ just --justfile "{{justfile()}}" _resolve_context2 "{{path}}" | source \
+ || { just --justfile "{{justfile()}}" _log error "Error: Could not resolve metadata for '$path'."; exit 1; }
pushd "{{REPO_DIR}}"
- just --justfile "{{justfile()}}" cross _log warn "The 'push' command is currently WORK IN PROGRESS."
- if not test -d $worktree
- just --justfile "{{justfile()}}" cross _log error "Error: Worktree not found. Run 'just patch' first."
+ set -l worktree_abs (just --justfile "{{justfile()}}" _resolve_patch_path "$worktree")
+ set -l local_abs (just --justfile "{{justfile()}}" _resolve_patch_path "$local_path")
+ just --justfile "{{justfile()}}" _log warn "The 'push' command is currently WORK IN PROGRESS."
+ if not test -d $worktree_abs
+ just --justfile "{{justfile()}}" _log error "Error: Worktree not found. Run 'just patch' first."
exit 1
end
- just --justfile "{{justfile()}}" cross _log info "Syncing changes from $local_path back to $worktree..."
- rsync -av --delete --exclude .git $local_path/ $worktree/$remote_path/
+ just --justfile "{{justfile()}}" _log info "Syncing changes from $local_path back to $worktree..."
+ rsync -av --delete --exclude .git $local_abs/ $worktree_abs/$remote_path/
- just --justfile "{{justfile()}}" cross _log info "---------------------------------------------------"
- just --justfile "{{justfile()}}" cross _log info "Worktree updated. Status:"
- git -C $worktree status
- just --justfile "{{justfile()}}" cross _log info "---------------------------------------------------"
+ just --justfile "{{justfile()}}" _log info "---------------------------------------------------"
+ just --justfile "{{justfile()}}" _log info "Worktree updated. Status:"
+ git -C $worktree_abs status
+ just --justfile "{{justfile()}}" _log info "---------------------------------------------------"
while true
if test "{{yes}}" = "true"
@@ -675,8 +712,8 @@ push path="" branch="" force="false" yes="false" message="": check-initialized
switch $choice
case r R
- just --justfile "{{justfile()}}" cross _log info "Preparing commit..."
- pushd $worktree
+ just --justfile "{{justfile()}}" _log info "Preparing commit..."
+ pushd $worktree_abs
git add .
# Determine commit message
@@ -697,7 +734,7 @@ push path="" branch="" force="false" yes="false" message="": check-initialized
if test -z "$msg"
set msg "Sync updates from $local_path"
end
- just --justfile "{{justfile()}}" cross _log info "Auto-generated message: $msg"
+ just --justfile "{{justfile()}}" _log info "Auto-generated message: $msg"
end
git commit -m "$msg"
@@ -717,22 +754,22 @@ push path="" branch="" force="false" yes="false" message="": check-initialized
set push_args $push_args "--force"
end
- just --justfile "{{justfile()}}" cross _log info "Pushing to $push_args..."
+ just --justfile "{{justfile()}}" _log info "Pushing to $push_args..."
git push $push_args
popd >/dev/null
break
case m M
- just --justfile "{{justfile()}}" cross _log info "Spawning subshell in $worktree..."
- just --justfile "{{justfile()}}" cross _log info "Type 'exit' to return."
+ just --justfile "{{justfile()}}" _log info "Spawning subshell in $worktree..."
+ just --justfile "{{justfile()}}" _log info "Type 'exit' to return."
pushd $worktree
fish
popd >/dev/null
- just --justfile "{{justfile()}}" cross _log info "Returned from manual mode."
+ just --justfile "{{justfile()}}" _log info "Returned from manual mode."
case c C
- just --justfile "{{justfile()}}" cross _log warn "Cancelled."
+ just --justfile "{{justfile()}}" _log warn "Cancelled."
exit 0
case '*'
- just --justfile "{{justfile()}}" cross _log error "Invalid choice."
+ just --justfile "{{justfile()}}" _log error "Invalid choice."
end
end
popd
@@ -743,12 +780,12 @@ push path="" branch="" force="false" yes="false" message="": check-initialized
list: check-deps
#!/usr/bin/env fish
pushd "{{REPO_DIR}}" >/dev/null
- if test -f .git/cross/metadata.json
+ if test -f {{METADATA}}
# Get unique remotes used by patches
- set used_remotes (jq -r '.patches[].remote' .git/cross/metadata.json | sort -u)
+ set used_remotes (jq -r '.patches[].remote' {{METADATA}} | sort -u)
if test (count $used_remotes) -gt 0
- just --justfile "{{justfile()}}" cross _log info "Configured Remotes:"
+ just --justfile "{{justfile()}}" _log info "Configured Remotes:"
printf "%-20s %s\n" "NAME" "URL"
printf "%s\n" (string repeat -n 70 "-")
@@ -780,28 +817,28 @@ list: check-deps
end
if not test -f Crossfile
- just --justfile "{{justfile()}}" cross _log warn "No patches found (Crossfile missing)."
+ just --justfile "{{justfile()}}" _log warn "No patches found (Crossfile missing)."
popd >/dev/null
exit 0
end
- just --justfile "{{justfile()}}" cross _log info "Configured Patches:"
+ just --justfile "{{justfile()}}" _log info "Configured Patches:"
printf "%-20s %-30s %-20s\n" "REMOTE" "REMOTE PATH" "LOCAL PATH"
printf "%s\n" (string repeat -n 70 "-")
- if test -f .git/cross/metadata.json
- jq -r '.patches[] | "\(.remote) \(.remote_path) \(.local_path)"' .git/cross/metadata.json | while read -l remote rpath lpath
+ if test -f {{METADATA}}
+ jq -r '.patches[] | "\(.remote) \(.remote_path) \(.local_path)"' {{METADATA}} | while read -l remote rpath lpath
printf "%-20s %-30s %-20s\n" $remote $rpath $lpath
end
else
- just --justfile "{{justfile()}}" cross _log info "No patches found. Run 'just --justfile "{{justfile()}}" cross patch ' to start."
+ just --justfile "{{justfile()}}" _log info "No patches found. Run 'just --justfile "{{justfile()}}" patch ' to start."
end
popd >/dev/null
# wt wrapper
[no-cd]
worktree path="":
- just --justfile "{{justfile()}}" cross cd "{{path}}" dry="{{dry}}"
+ just --justfile "{{justfile()}}" cd "{{path}}" dry="{{dry}}"
# Internal: Copy text to clipboard (cross-platform)
[no-cd]
@@ -827,9 +864,10 @@ _copy_to_clipboard text:
[no-cd]
_open_shell target path:
#!/usr/bin/env fish
- just --justfile "{{justfile()}}" cross _resolve_context2 "{{path}}" | source || exit 1
+ just --justfile "{{justfile()}}" _resolve_context2 "{{path}}" | source || exit 1
set -l dir (test "{{target}}" = "local_path" && echo $local_path || echo $worktree)
- cd {{REPO_DIR}}/$dir && exec $SHELL
+ set -l resolved_dir (just --justfile "{{justfile()}}" _resolve_patch_path "$dir")
+ cd $resolved_dir && exec $SHELL
# Go to worktree directory (for working with git history)
# With path: opens subshell
@@ -838,14 +876,15 @@ _open_shell target path:
wt path="":
#!/usr/bin/env fish
if test -n "{{path}}"
- just --justfile "{{justfile()}}" cross _open_shell worktree "{{path}}"
+ just --justfile "{{justfile()}}" _open_shell worktree "{{path}}"
else
- set -l selected (just --justfile "{{justfile()}}" cross list | tail -n +3 | fzf --height 40% 2>/dev/null | awk '{print $NF}')
+ set -l selected (just --justfile "{{justfile()}}" list | tail -n +3 | fzf --height 40% 2>/dev/null | awk '{print $NF}')
test -z "$selected" && exit 0
- just --justfile "{{justfile()}}" cross _resolve_context2 "$selected" | source || exit 1
- set -l rel_dir (realpath -m --relative-to=$PWD {{REPO_DIR}}/$worktree)
- just --justfile "{{justfile()}}" cross _copy_to_clipboard $rel_dir
- just --justfile "{{justfile()}}" cross _log success "Path copied: $rel_dir"
+ just --justfile "{{justfile()}}" _resolve_context2 "$selected" | source || exit 1
+ set -l wt_path (just --justfile "{{justfile()}}" _resolve_patch_path "$worktree")
+ set -l rel_dir (realpath -m --relative-to=$PWD $wt_path)
+ just --justfile "{{justfile()}}" _copy_to_clipboard $rel_dir
+ just --justfile "{{justfile()}}" _log success "Path copied: $rel_dir"
end
# Go to local_path directory (for editing patched files)
@@ -855,14 +894,15 @@ wt path="":
cd path="":
#!/usr/bin/env fish
if test -n "{{path}}"
- just --justfile "{{justfile()}}" cross _open_shell local_path "{{path}}"
+ just --justfile "{{justfile()}}" _open_shell local_path "{{path}}"
else
- set -l selected (just --justfile "{{justfile()}}" cross list | tail -n +3 | fzf --height 40% 2>/dev/null | awk '{print $NF}')
+ set -l selected (just --justfile "{{justfile()}}" list | tail -n +3 | fzf --height 40% 2>/dev/null | awk '{print $NF}')
test -z "$selected" && exit 0
- just --justfile "{{justfile()}}" cross _resolve_context2 "$selected" | source || exit 1
- set -l rel_dir (realpath -m --relative-to=$PWD {{REPO_DIR}}/$local_path)
- just --justfile "{{justfile()}}" cross _copy_to_clipboard $rel_dir
- just --justfile "{{justfile()}}" cross _log success "Path copied: $rel_dir"
+ just --justfile "{{justfile()}}" _resolve_context2 "$selected" | source || exit 1
+ set -l lp_path (just --justfile "{{justfile()}}" _resolve_patch_path "$local_path")
+ set -l rel_dir (realpath -m --relative-to=$PWD $lp_path)
+ just --justfile "{{justfile()}}" _copy_to_clipboard $rel_dir
+ just --justfile "{{justfile()}}" _log success "Path copied: $rel_dir"
end
@@ -872,39 +912,42 @@ cd path="":
status: check-deps
#!/usr/bin/env fish
if not test -f Crossfile
- just --justfile "{{justfile()}}" cross _log warn "No patches found."
+ just --justfile "{{justfile()}}" _log warn "No patches found."
exit 0
end
+ pushd "{{REPO_DIR}}" >/dev/null
printf "%-20s %-15s %-15s %-15s\n" "LOCAL PATH" "DIFF" "UPSTREAM" "CONFLICTS"
printf "%s\n" (string repeat -n 70 "-")
-
- if test -f .git/cross/metadata.json
- jq -r '.patches[] | "\(.remote) \(.remote_path) \(.local_path) \(.worktree)"' .git/cross/metadata.json | while read -l remote rpath local_path wt
+
+ if test -f {{METADATA}}
+ jq -r '.patches[] | "\(.remote) \(.remote_path) \(.local_path) \(.worktree)"' {{METADATA}} | while read -l remote rpath local_path wt
set diff_stat "Clean"
set upstream_stat "Synced"
set conflict_stat "No"
-
- if test -d $wt
+ set wt_abs (just --justfile "{{justfile()}}" _resolve_patch_path "$wt")
+ set local_abs (just --justfile "{{justfile()}}" _resolve_patch_path "$local_path")
+
+ if test -d $wt_abs
# Check diffs
- if not git diff --no-index --quiet $wt/$rpath $local_path 2>/dev/null
+ if not git diff --no-index --quiet $wt_abs/$rpath $local_abs 2>/dev/null
set diff_stat "Modified"
end
-
+
# Check upstream divergence
- set behind (git -C $wt rev-list --count HEAD..@{upstream} 2>/dev/null)
- set ahead (git -C $wt rev-list --count @{upstream}..HEAD 2>/dev/null)
+ set behind (git -C $wt_abs rev-list --count HEAD..@{upstream} 2>/dev/null)
+ set ahead (git -C $wt_abs rev-list --count @{upstream}..HEAD 2>/dev/null)
if test "$behind" -gt 0
set upstream_stat "$behind behind"
else if test "$ahead" -gt 0
set upstream_stat "$ahead ahead"
end
-
+
# Check conflicts in worktree
- if git -C $wt ls-files -u | grep -q .
+ if git -C $wt_abs ls-files -u | grep -q .
set conflict_stat "YES"
end
-
+
# Also check conflicts in local path (from failed stash restore)
if git ls-files -u $local_path | grep -q .
set conflict_stat "YES"
@@ -916,8 +959,9 @@ status: check-deps
printf "%-20s %-15s %-15s %-15s\n" $local_path $diff_stat $upstream_stat $conflict_stat
end
else
- just --justfile "{{justfile()}}" cross _log info "No patches found."
+ just --justfile "{{justfile()}}" _log info "No patches found."
end
+ popd >/dev/null
# Replay commands from Crossfile
replay: check-deps
diff --git a/README.md b/README.md
index 90ff05f7b..0819e8da1 100644
--- a/README.md
+++ b/README.md
@@ -2,7 +2,7 @@
[](https://github.com/epcim/git-cross/actions/workflows/ci.yml)
[](https://opensource.org/licenses/MIT)
-[](https://github.com/epcim/git-cross/blob/main/CHANGELOG.md)
+[](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`.
@@ -145,6 +145,13 @@ cross exec "npm install && npm run build"
> **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.
+### Independent worktree support *(new in v0.2.2)*
+`git-cross` now detects the shared Git directory even when you operate from a linked worktree (`git worktree add …`).
+
+- **Just**, **Go**, and **Rust** CLIs honour the `CROSSDIR`/`METADATA` environment overrides used in tests and automation.
+- When no overrides are set, the CLIs resolve `.git/commondir` so patches, syncs, and metadata land in the correct parent repository.
+- Automated tests (`just cross-test 019`) cover patching from an independent worktree across all implementations.
+
### Just Integration
If using `just`, you can override targets to add pre/post hooks:
```just
diff --git a/TODO.md b/TODO.md
index a9110566c..ee0471bf0 100644
--- a/TODO.md
+++ b/TODO.md
@@ -2,9 +2,9 @@
## Summary
-**Status:** v0.2.1 released with prune command and sync fixes
+**Status:** v0.2.2 released with independent worktree support
**Critical Issues:** 0 (all P0 issues resolved)
-**Pending Enhancements:** 2 (single-file patch, fzf improvements)
+**Pending Enhancements:** 4 (single-file patch, fzf improvements, context-aware diff, linked worktree edge case)
## Core Implementation Status
@@ -49,9 +49,9 @@
- **Behavior:**
- `cross prune`: Finds unused remotes, asks for confirmation, removes them, prunes stale worktrees
- `cross prune `: Removes all patches for that remote, then removes the remote itself
- - **Status:** COMPLETE - Ready for v0.2.1 release
+ - **Status:** COMPLETE - Shipped in v0.2.1
-- [ ] Extend patch test. Create git repo. Create a new branch `featA`. Create a new independent git worktree in new working directory by `git worktree add $PWD-featA`; cd there; on this featA start testing `cross patch`.
+- [x] Extend patch test to cover linked worktrees (test/019). Create git repo, create branch `featA`, add independent git worktree, and run `cross patch` across Just, Go, and Rust implementations.
### P2: Medium Priority
- [x] **Fix `cross cd` and `cross wt` commands** - Correct behavior for navigation
@@ -195,7 +195,7 @@
**Testing:** Run `just cross-test 004` to validate all scenarios
**Impact:** Data loss risk eliminated, file synchronization complete
-**Status:** FIXED - Ready for v0.2.1 release
+**Status:** FIXED - Shipped in v0.2.1
- [x] Updates to Crossfile can create duplicit lines (especially if user add spaces between remote_spec and local_spec.) Ideally we shall only check whether the local/path is already specified, and if yes then avoid update and avoid patch (as path exist.)
- [x] Extend the tests, start using and sub-path apps/ for "patches". Document this in test-case design.
- [x] Looks like the worktree created dont have any more "sparse checkout". Extend the validation, ie: that no other top-level files present in checkouts (assuming sub-path is used on remote repo)
diff --git a/VERSION b/VERSION
index 0c62199f1..ee1372d33 100644
--- a/VERSION
+++ b/VERSION
@@ -1 +1 @@
-0.2.1
+0.2.2
diff --git a/src-go/main.go b/src-go/main.go
index b8d1a0918..c1185e14c 100644
--- a/src-go/main.go
+++ b/src-go/main.go
@@ -9,6 +9,7 @@ import (
"os/exec"
"path/filepath"
"strings"
+ "sync"
"github.com/fatih/color"
"github.com/gogs/git-module"
@@ -18,10 +19,48 @@ import (
)
const (
- MetadataRelPath = ".git/cross/metadata.json"
+ MetadataRelPath = "cross/metadata.json"
CrossfileRelPath = "Crossfile"
)
+var (
+ gitDirOnce sync.Once
+ gitDir string
+ gitDirErr error
+)
+
+func getGitCommonDir() (string, error) {
+ gitDirOnce.Do(func() {
+ if envCross := os.Getenv("CROSSDIR"); envCross != "" {
+ gitDir = filepath.Dir(filepath.Clean(envCross))
+ return
+ }
+ out, err := exec.Command("git", "rev-parse", "--path-format=absolute", "--git-dir").Output()
+ if err != nil {
+ gitDirErr = err
+ return
+ }
+ absGitDir := filepath.Clean(strings.TrimSpace(string(out)))
+ commondirPath := filepath.Join(absGitDir, "commondir")
+ if data, readErr := os.ReadFile(commondirPath); readErr == nil {
+ rel := strings.TrimSpace(string(data))
+ if rel != "" {
+ candidate := filepath.Clean(filepath.Join(absGitDir, rel))
+ gitDir = candidate
+ return
+ }
+ } else if !os.IsNotExist(readErr) {
+ gitDirErr = readErr
+ return
+ }
+ gitDir = absGitDir
+ })
+ if gitDirErr != nil {
+ return "", gitDirErr
+ }
+ return gitDir, nil
+}
+
func getRepoRoot() (string, error) {
out, err := exec.Command("git", "rev-parse", "--show-toplevel").Output()
if err != nil {
@@ -31,11 +70,14 @@ func getRepoRoot() (string, error) {
}
func getMetadataPath() (string, error) {
- root, err := getRepoRoot()
+ if envMeta := os.Getenv("METADATA"); envMeta != "" {
+ return filepath.Clean(envMeta), nil
+ }
+ gitDir, err := getGitCommonDir()
if err != nil {
return "", err
}
- return filepath.Join(root, MetadataRelPath), nil
+ return filepath.Join(gitDir, MetadataRelPath), nil
}
func getCrossfilePath() (string, error) {
@@ -432,7 +474,7 @@ func main() {
var dry string
rootCmd := &cobra.Command{
Use: "git-cross",
- Version: "0.2.1",
+ Version: "0.2.2",
}
rootCmd.PersistentFlags().StringVar(&dry, "dry", "", "Dry run command (e.g. echo)")
@@ -535,7 +577,12 @@ func main() {
h.Write([]byte(spec.Remote + spec.RemotePath + spec.Branch))
hash := hex.EncodeToString(h.Sum(nil))[:8]
- wtDir := fmt.Sprintf(".git/cross/worktrees/%s_%s", spec.Remote, hash)
+ gitDir, err := getGitCommonDir()
+ if err != nil {
+ return err
+ }
+
+ wtDir := filepath.Join(gitDir, "cross", "worktrees", fmt.Sprintf("%s_%s", spec.Remote, hash))
if _, err := os.Stat(wtDir); os.IsNotExist(err) {
logInfo(fmt.Sprintf("Setting up worktree at %s...", wtDir))
if err := os.MkdirAll(wtDir, 0o755); err != nil {
diff --git a/src-rust/Cargo.lock b/src-rust/Cargo.lock
index fb3e0089b..92fec7e88 100644
--- a/src-rust/Cargo.lock
+++ b/src-rust/Cargo.lock
@@ -208,7 +208,7 @@ dependencies = [
[[package]]
name = "git-cross-rust"
-version = "0.2.1"
+version = "0.2.2"
dependencies = [
"anyhow",
"clap",
diff --git a/src-rust/Cargo.toml b/src-rust/Cargo.toml
index 203945b90..eaeb4986c 100644
--- a/src-rust/Cargo.toml
+++ b/src-rust/Cargo.toml
@@ -1,6 +1,6 @@
[package]
name = "git-cross-rust"
-version = "0.2.1"
+version = "0.2.2"
edition = "2024"
[dependencies]
diff --git a/src-rust/src/main.rs b/src-rust/src/main.rs
index 4ea759a66..5991da108 100644
--- a/src-rust/src/main.rs
+++ b/src-rust/src/main.rs
@@ -4,13 +4,13 @@ use serde::{Deserialize, Serialize};
use std::env;
use std::fs;
use std::io::{ErrorKind, Write};
-use std::path::Path;
+use std::path::{Path, PathBuf};
use std::process::{Command, Stdio};
use tabled::{Table, Tabled};
#[derive(Parser)]
#[command(name = "git-cross-rust")]
-#[command(version = "0.2.1")]
+#[command(version = "0.2.2")]
#[command(
about = "A tool for vendoring git directories using worktrees [EXPERIMENTAL/WIP]",
long_about = "Note: The Rust implementation of git-cross is currently EXPERIMENTAL and WORK IN PROGRESS. The Go implementation is the primary focus and recommended for production use."
@@ -116,16 +116,46 @@ struct PatchSpec {
branch_provided: bool,
}
-const METADATA_REL_PATH: &str = ".git/cross/metadata.json";
const CROSSFILE_REL_PATH: &str = "Crossfile";
+fn git_common_dir() -> Result {
+ if let Ok(cross_env) = env::var("CROSSDIR") {
+ let cross_path = Path::new(&cross_env)
+ .canonicalize()
+ .unwrap_or_else(|_| Path::new(&cross_env).to_path_buf());
+ if let Some(parent) = cross_path.parent() {
+ return Ok(parent.to_path_buf());
+ }
+ return Ok(cross_path);
+ }
+
+ let abs_git_dir = run_cmd(&["git", "rev-parse", "--path-format=absolute", "--git-dir"])?;
+ let abs_git_path = Path::new(&abs_git_dir)
+ .canonicalize()
+ .unwrap_or_else(|_| Path::new(&abs_git_dir).to_path_buf());
+
+ let commondir_path = abs_git_path.join("commondir");
+ if let Ok(rel) = fs::read_to_string(&commondir_path) {
+ let joined = abs_git_path.join(rel.trim());
+ let resolved = joined.canonicalize().unwrap_or(joined);
+ return Ok(resolved);
+ }
+
+ Ok(abs_git_path)
+}
+
fn get_repo_root() -> Result {
run_cmd(&["git", "rev-parse", "--show-toplevel"])
}
fn get_metadata_path() -> Result {
- let root = get_repo_root()?;
- Ok(Path::new(&root).join(METADATA_REL_PATH))
+ if let Ok(meta_env) = env::var("METADATA") {
+ return Ok(Path::new(&meta_env)
+ .canonicalize()
+ .unwrap_or_else(|_| Path::new(&meta_env).to_path_buf()));
+ }
+ let git_dir = git_common_dir()?;
+ Ok(git_dir.join("cross/metadata.json"))
}
fn get_crossfile_path() -> Result {
@@ -429,10 +459,14 @@ fn copy_to_clipboard(text: &str) -> Result<()> {
duct::cmd!("pbcopy").stdin_bytes(text).run()?;
} else if which::which("xclip").is_ok() {
// Linux with xclip
- duct::cmd!("xclip", "-selection", "clipboard").stdin_bytes(text).run()?;
+ duct::cmd!("xclip", "-selection", "clipboard")
+ .stdin_bytes(text)
+ .run()?;
} else if which::which("xsel").is_ok() {
// Linux with xsel
- duct::cmd!("xsel", "--clipboard", "--input").stdin_bytes(text).run()?;
+ duct::cmd!("xsel", "--clipboard", "--input")
+ .stdin_bytes(text)
+ .run()?;
} else {
return Err(anyhow!("No clipboard tool found (pbcopy/xclip/xsel)"));
}
@@ -441,30 +475,31 @@ fn copy_to_clipboard(text: &str) -> Result<()> {
fn get_relative_path(target_path: &str) -> String {
use std::path::PathBuf;
-
+
// Get current working directory
let Ok(pwd) = env::current_dir() else {
return target_path.to_string();
};
-
+
// Convert target to absolute path (don't use canonicalize - it requires file to exist)
let target = PathBuf::from(target_path);
-
+
// Manual computation: try strip_prefix first (if target is subpath of pwd)
if let Ok(rel) = target.strip_prefix(&pwd) {
return rel.to_string_lossy().to_string();
}
-
+
// Otherwise compute relative path by finding common prefix
let pwd_components: Vec<_> = pwd.components().collect();
let target_components: Vec<_> = target.components().collect();
-
+
// Find common prefix
- let common = pwd_components.iter()
+ let common = pwd_components
+ .iter()
.zip(target_components.iter())
.take_while(|(a, b)| a == b)
.count();
-
+
// Build relative path: ../ for each level up, then remaining target components
let ups = pwd_components.len() - common;
let mut rel_path = PathBuf::new();
@@ -474,7 +509,7 @@ fn get_relative_path(target_path: &str) -> String {
for comp in &target_components[common..] {
rel_path.push(comp);
}
-
+
rel_path.to_string_lossy().to_string()
}
@@ -488,7 +523,13 @@ fn open_shell_in_dir(path: &str, target_type: &str) -> Result<()> {
// Find patch for provided path
let path = path.trim();
let target_patch = find_patch_for_path(&metadata, path)
- .or_else(|| metadata.patches.iter().find(|p| p.local_path == path).cloned())
+ .or_else(|| {
+ metadata
+ .patches
+ .iter()
+ .find(|p| p.local_path == path)
+ .cloned()
+ })
.ok_or_else(|| anyhow!("Patch not found for path: {}", path))?;
// Determine target directory
@@ -552,22 +593,20 @@ fn resolve_path_to_repo_relative(input_path: &str) -> Result {
};
// Canonicalize/clean the path (resolves . and ..)
- let abs_path = abs_path
- .canonicalize()
- .unwrap_or_else(|_| {
- // If canonicalize fails (path doesn't exist), manually clean it
- let mut cleaned = std::path::PathBuf::new();
- for component in abs_path.components() {
- match component {
- std::path::Component::ParentDir => {
- cleaned.pop();
- }
- std::path::Component::CurDir => {}
- _ => cleaned.push(component),
+ let abs_path = abs_path.canonicalize().unwrap_or_else(|_| {
+ // If canonicalize fails (path doesn't exist), manually clean it
+ let mut cleaned = std::path::PathBuf::new();
+ for component in abs_path.components() {
+ match component {
+ std::path::Component::ParentDir => {
+ cleaned.pop();
}
+ std::path::Component::CurDir => {}
+ _ => cleaned.push(component),
}
- cleaned
- });
+ }
+ cleaned
+ });
// Get relative path from repo root
let rel_path = abs_path
@@ -622,7 +661,9 @@ fn update_crossfile(line: &str) -> Result<()> {
let already_exists = content.lines().any(|l| {
let trimmed_l = l.trim();
- trimmed_l == line || trimmed_l == format!("cross {}", line) || trimmed_l == line_without_prefix
+ trimmed_l == line
+ || trimmed_l == format!("cross {}", line)
+ || trimmed_l == line_without_prefix
});
if !already_exists {
@@ -704,11 +745,16 @@ fn main() -> Result<()> {
let hash = format!("{:016x}", hasher.finish());
let hash = &hash[..8];
- let wt_dir = format!(".git/cross/worktrees/{}_{}", spec.remote, hash);
+ let cross_worktrees = git_common_dir()?.join("cross/worktrees");
+ fs::create_dir_all(&cross_worktrees)?;
+ let wt_dir_path = cross_worktrees.join(format!("{}_{}", spec.remote, hash));
+ let wt_dir = wt_dir_path.to_string_lossy().to_string();
- if !Path::new(&wt_dir).exists() {
+ if !wt_dir_path.exists() {
log_info(&format!("Setting up worktree at {}...", wt_dir));
- fs::create_dir_all(&wt_dir)?;
+ if let Some(parent) = wt_dir_path.parent() {
+ fs::create_dir_all(parent)?;
+ }
run_cmd(&[
"git",
@@ -884,7 +930,9 @@ fn main() -> Result<()> {
log_error("Please resolve conflicts manually in worktree:");
log_error(&format!(" cd {}", patch.worktree));
if stashed {
- log_info("Note: Local changes are stashed. Run 'git stash pop' in local_path after resolving.");
+ log_info(
+ "Note: Local changes are stashed. Run 'git stash pop' in local_path after resolving.",
+ );
}
continue;
}
@@ -892,12 +940,12 @@ fn main() -> Result<()> {
// Step 5: Delete tracked files in local_path that were removed upstream
log_info("Checking for files deleted upstream...");
let wt_remote_path = format!("{}/{}", patch.worktree, patch.remote_path);
-
+
// Get tracked files in worktree
if let Ok(wt_files_str) = run_cmd(&["git", "-C", &wt_remote_path, "ls-files"]) {
- let wt_files: std::collections::HashSet =
+ let wt_files: std::collections::HashSet =
wt_files_str.lines().map(|s| s.to_string()).collect();
-
+
// Get tracked files in local_path from main repo
if let Ok(local_files_str) = run_cmd(&["git", "ls-files", &patch.local_path]) {
for local_file in local_files_str.lines() {
@@ -905,19 +953,21 @@ fn main() -> Result<()> {
continue;
}
// Get relative path (remove local_path prefix)
- let rel_file = local_file.strip_prefix(&format!("{}/", patch.local_path))
+ let rel_file = local_file
+ .strip_prefix(&format!("{}/", patch.local_path))
.unwrap_or(local_file);
-
+
// Check if this tracked file no longer exists in worktree
if !wt_files.contains(rel_file) {
log_info(&format!("Removing deleted file: {}", rel_file));
- let full_path = format!("{}/{}", env::current_dir()?.display(), local_file);
+ let full_path =
+ format!("{}/{}", env::current_dir()?.display(), local_file);
let _ = std::fs::remove_file(&full_path);
}
}
}
}
-
+
// Step 6: Rsync worktree → local_path (without --delete, we handle deletions above)
log_info(&format!("Syncing files to {}...", patch.local_path));
let src = format!("{}/{}/", patch.worktree, patch.remote_path);
@@ -936,7 +986,9 @@ fn main() -> Result<()> {
if let Err(e) = run_cmd(&["git", "-C", &local_abs_path, "stash", "pop"]) {
log_error("Failed to restore stashed changes. Conflicts may exist.");
log_error(&format!("Resolve manually in: {}", patch.local_path));
- log_info("Run 'git status' to see conflicts, then 'git stash drop' when resolved.");
+ log_info(
+ "Run 'git status' to see conflicts, then 'git stash drop' when resolved.",
+ );
} else {
// Check for conflicts after pop
if let Ok(conflicts) = run_cmd(&[
@@ -1042,7 +1094,9 @@ fn main() -> Result<()> {
continue;
}
- let entry = remote_map.entry(name.to_string()).or_insert_with(|| (String::new(), String::new()));
+ let entry = remote_map
+ .entry(name.to_string())
+ .or_insert_with(|| (String::new(), String::new()));
if rtype.contains("fetch") {
entry.0 = url.to_string();
} else if rtype.contains("push") {
@@ -1126,7 +1180,7 @@ 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);
-
+
let diff_check = duct::cmd(
"git",
[
@@ -1168,14 +1222,27 @@ fn main() -> Result<()> {
row.upstream = format!("{} ahead", ahead);
}
- match run_cmd(&["git", "-C", &worktree_path.to_string_lossy(), "ls-files", "-u"]) {
+ match run_cmd(&[
+ "git",
+ "-C",
+ &worktree_path.to_string_lossy(),
+ "ls-files",
+ "-u",
+ ]) {
Ok(c) if !c.is_empty() => row.conflicts = "YES".to_string(),
_ => (),
}
-
+
// Also check conflicts in local path (from failed stash restore)
let local_abs_path = Path::new(&root).join(&patch.local_path);
- match run_cmd(&["git", "-C", &root, "ls-files", "-u", &local_abs_path.to_string_lossy()]) {
+ match run_cmd(&[
+ "git",
+ "-C",
+ &root,
+ "ls-files",
+ "-u",
+ &local_abs_path.to_string_lossy(),
+ ]) {
Ok(c) if !c.is_empty() => row.conflicts = "YES".to_string(),
_ => (),
}
@@ -1202,7 +1269,8 @@ fn main() -> Result<()> {
// 1. Remove worktree
if Path::new(&patch.worktree).exists() {
log_info(&format!("Removing git worktree at {}...", patch.worktree));
- if let Err(e) = run_cmd(&["git", "worktree", "remove", "--force", &patch.worktree]) {
+ if let Err(e) = run_cmd(&["git", "worktree", "remove", "--force", &patch.worktree])
+ {
log_error(&format!("Failed to remove worktree: {}", e));
}
}
@@ -1238,11 +1306,14 @@ fn main() -> Result<()> {
}
Commands::Prune { remote } => {
let mut metadata = load_metadata()?;
-
+
if let Some(remote_name) = remote {
// Prune specific remote: remove all its patches
- log_info(&format!("Pruning all patches for remote: {}...", remote_name));
-
+ log_info(&format!(
+ "Pruning all patches for remote: {}...",
+ remote_name
+ ));
+
// Find all patches for this remote
let patches_to_remove: Vec = metadata
.patches
@@ -1250,25 +1321,28 @@ fn main() -> Result<()> {
.filter(|p| p.remote == *remote_name)
.cloned()
.collect();
-
+
if patches_to_remove.is_empty() {
log_info(&format!("No patches found for remote: {}", remote_name));
} else {
// Remove each patch
for patch in patches_to_remove {
log_info(&format!("Removing patch: {}", patch.local_path));
-
+
// Remove worktree
if Path::new(&patch.worktree).exists() {
- let _ = run_cmd(&["git", "worktree", "remove", "--force", &patch.worktree]);
+ let _ =
+ run_cmd(&["git", "worktree", "remove", "--force", &patch.worktree]);
}
-
+
// Remove from Crossfile
if let Ok(cross_path) = get_crossfile_path() {
if let Ok(content) = fs::read_to_string(&cross_path) {
let lines: Vec = content
.lines()
- .filter(|l| !l.contains("patch") || !l.contains(&patch.local_path))
+ .filter(|l| {
+ !l.contains("patch") || !l.contains(&patch.local_path)
+ })
.map(|l| l.to_string())
.collect();
let mut new_content = lines.join("\n");
@@ -1278,16 +1352,18 @@ fn main() -> Result<()> {
let _ = fs::write(&cross_path, new_content);
}
}
-
+
// Remove from metadata
- metadata.patches.retain(|p| p.local_path != patch.local_path);
-
+ metadata
+ .patches
+ .retain(|p| p.local_path != patch.local_path);
+
// Remove local directory
let _ = fs::remove_dir_all(&patch.local_path);
}
save_metadata(&metadata)?;
}
-
+
// Remove the remote itself
if let Ok(remotes) = run_cmd(&["git", "remote"]) {
if remotes.lines().any(|r| r.trim() == remote_name) {
@@ -1295,16 +1371,19 @@ fn main() -> Result<()> {
let _ = run_cmd(&["git", "remote", "remove", remote_name]);
}
}
-
- log_success(&format!("Remote {} and all its patches pruned successfully.", remote_name));
+
+ log_success(&format!(
+ "Remote {} and all its patches pruned successfully.",
+ remote_name
+ ));
} else {
// Prune all unused remotes (no active patches)
log_info("Finding unused remotes...");
-
+
// Get all remotes used by patches
- let used_remotes: std::collections::HashSet =
+ let used_remotes: std::collections::HashSet =
metadata.patches.iter().map(|p| p.remote.clone()).collect();
-
+
// Get all git remotes
let all_remotes = run_cmd(&["git", "remote"])?;
let all_remotes: Vec = all_remotes
@@ -1312,23 +1391,23 @@ fn main() -> Result<()> {
.map(|s| s.trim().to_string())
.filter(|r| !r.is_empty() && r != "origin" && r != "git-cross")
.collect();
-
+
// Find unused remotes
let unused_remotes: Vec = all_remotes
.into_iter()
.filter(|r| !used_remotes.contains(r))
.collect();
-
+
if unused_remotes.is_empty() {
log_info("No unused remotes found.");
} else {
log_info(&format!("Unused remotes: {}", unused_remotes.join(", ")));
print!("Remove these remotes? [y/N]: ");
std::io::stdout().flush()?;
-
+
let mut input = String::new();
std::io::stdin().read_line(&mut input)?;
-
+
if input.trim().to_lowercase() == "y" {
for remote in unused_remotes {
log_info(&format!("Removing remote: {}", remote));
@@ -1339,7 +1418,7 @@ fn main() -> Result<()> {
log_info("Pruning cancelled.");
}
}
-
+
// Always prune stale worktrees
log_info("Pruning stale worktrees...");
let _ = run_cmd(&["git", "worktree", "prune", "--verbose"]);
@@ -1364,7 +1443,7 @@ fn main() -> Result<()> {
continue;
}
found = true;
-
+
// Resolve worktree path relative to repo root
let worktree_path = Path::new(&root).join(&patch.worktree);
if !worktree_path.exists() {
@@ -1375,11 +1454,18 @@ 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 _ = duct::cmd(
+ "git",
+ [
+ "diff",
+ "--no-index",
+ &upstream_path.to_string_lossy(),
+ &local_path.to_string_lossy(),
+ ],
+ )
+ .run();
}
if !found && !resolved_path.is_empty() {
return Err(anyhow!("Patch not found for path: {}", resolved_path));
diff --git a/test/019_patch_worktree.sh b/test/019_patch_worktree.sh
new file mode 100755
index 000000000..d8bfa2cba
--- /dev/null
+++ b/test/019_patch_worktree.sh
@@ -0,0 +1,232 @@
+#!/usr/bin/env bash
+
+set -euo pipefail
+
+source "$(dirname "$0")/common.sh"
+
+REPO_ROOT=$(pwd)
+TEST_BASE=$(mktemp -d)
+
+GO_BIN="$REPO_ROOT/src-go/git-cross-go"
+RUST_BIN="$REPO_ROOT/src-rust/target/debug/git-cross-rust"
+
+compute_crossdir() {
+ local git_dir
+ pushd "$REPO_ROOT" >/dev/null
+ git_dir=$(git rev-parse --path-format=absolute --git-dir)
+ if [ -f "$git_dir/commondir" ]; then
+ local rel
+ rel=$(cat "$git_dir/commondir")
+ pushd "$git_dir" >/dev/null
+ git_dir=$(cd "$rel" && pwd)
+ popd >/dev/null
+ fi
+ popd >/dev/null
+ printf "%s/cross" "$git_dir"
+}
+
+build_go_binary() {
+ if [ ! -x "$GO_BIN" ]; then
+ log_info "Building Go binary at $GO_BIN"
+ (cd "$REPO_ROOT/src-go" && go build -o git-cross-go main.go)
+ fi
+}
+
+build_rust_binary() {
+ if [ ! -x "$RUST_BIN" ]; then
+ log_info "Building Rust binary at $RUST_BIN"
+ (cd "$REPO_ROOT/src-rust" && cargo build >/dev/null)
+ fi
+}
+
+prepare_upstream_repo() {
+ local repo_name=$1
+ local fixture_text=$2
+
+ local path
+ path=$(create_upstream "$repo_name")
+
+ pushd "$path" >/dev/null
+ mkdir -p src/lib
+ echo "$fixture_text" > src/lib/lib.txt
+ git add src/lib/lib.txt
+ git commit -m "Add worktree patch fixture" -q
+ popd >/dev/null
+
+ echo "$path"
+}
+
+assert_cross_metadata_present() {
+ local description=$1
+ local crossdir
+ crossdir=$(compute_crossdir)
+
+ if [ ! -f "$crossdir/metadata.json" ]; then
+ log_error "$description: expected $crossdir/metadata.json to exist"
+ exit 1
+ fi
+}
+
+cleanup_worktree_dir() {
+ local worktree_path=$1
+ if [ -d "$worktree_path" ]; then
+ git worktree remove -f "$worktree_path" >/dev/null 2>&1 || true
+ rm -rf "$worktree_path"
+ fi
+}
+
+run_just_patch_in_worktree() {
+ log_header "Justfile implementation - patch inside independent git worktree"
+
+ setup_sandbox "$TEST_BASE"
+ cd "$SANDBOX"
+
+ local upstream_path upstream_url worktree_path branch
+ upstream_path=$(prepare_upstream_repo "worktree-just" "just worktree fixture")
+ upstream_url="file://$upstream_path"
+ branch="featA"
+
+ if git show-ref --verify --quiet "refs/heads/$branch"; then
+ git branch -f "$branch" >/dev/null
+ else
+ git branch "$branch" >/dev/null
+ fi
+ worktree_path="${SANDBOX}-${branch}"
+ cleanup_worktree_dir "$worktree_path"
+ git worktree add "$worktree_path" "$branch" >/dev/null
+
+ pushd "$worktree_path" >/dev/null
+ just cross use repo1 "$upstream_url"
+ just cross patch repo1:src/lib vendor/worktree-lib
+
+ if [ ! -f "vendor/worktree-lib/lib.txt" ]; then
+ log_error "Just implementation: vendor/worktree-lib/lib.txt missing"
+ exit 1
+ fi
+
+ if ! grep -q "just worktree fixture" "vendor/worktree-lib/lib.txt"; then
+ log_error "Just implementation: lib.txt content mismatch"
+ exit 1
+ fi
+
+ if ! grep -q "cross patch repo1:main:src/lib vendor/worktree-lib" Crossfile; then
+ log_error "Just implementation: Crossfile missing patch entry"
+ exit 1
+ fi
+
+ assert_cross_metadata_present "Just implementation"
+ popd >/dev/null
+
+ cleanup_worktree_dir "$worktree_path"
+ log_success "Just implementation handled git worktree patch"
+ cd "$REPO_ROOT"
+}
+
+run_go_patch_in_worktree() {
+ log_header "Go implementation - patch inside independent git worktree"
+
+ setup_sandbox "$TEST_BASE"
+ cd "$SANDBOX"
+
+ build_go_binary
+
+ local upstream_path upstream_url worktree_path branch
+ upstream_path=$(prepare_upstream_repo "worktree-go" "go worktree fixture")
+ upstream_url="file://$upstream_path"
+ branch="featA"
+
+ if git show-ref --verify --quiet "refs/heads/$branch"; then
+ git branch -f "$branch" >/dev/null
+ else
+ git branch "$branch" >/dev/null
+ fi
+ worktree_path="${SANDBOX}-${branch}"
+ cleanup_worktree_dir "$worktree_path"
+ git worktree add "$worktree_path" "$branch" >/dev/null
+
+ pushd "$worktree_path" >/dev/null
+ crossdir="$(compute_crossdir)"
+ metadata="$crossdir/metadata.json"
+ CROSSDIR="$crossdir" METADATA="$metadata" "$GO_BIN" use repo1 "$upstream_url"
+ CROSSDIR="$crossdir" METADATA="$metadata" "$GO_BIN" patch repo1:src/lib vendor/worktree-lib
+
+ if [ ! -f "vendor/worktree-lib/lib.txt" ]; then
+ log_error "Go implementation: vendor/worktree-lib/lib.txt missing"
+ exit 1
+ fi
+
+ if ! grep -q "go worktree fixture" "vendor/worktree-lib/lib.txt"; then
+ log_error "Go implementation: lib.txt content mismatch"
+ exit 1
+ fi
+
+ if ! grep -q "cross patch repo1:main:src/lib vendor/worktree-lib" Crossfile; then
+ log_error "Go implementation: Crossfile missing patch entry"
+ exit 1
+ fi
+
+ assert_cross_metadata_present "Go implementation"
+ popd >/dev/null
+
+ cleanup_worktree_dir "$worktree_path"
+ log_success "Go implementation handled git worktree patch"
+ cd "$REPO_ROOT"
+}
+
+run_rust_patch_in_worktree() {
+ log_header "Rust implementation - patch inside independent git worktree"
+
+ setup_sandbox "$TEST_BASE"
+ cd "$SANDBOX"
+
+ build_rust_binary
+
+ local upstream_path upstream_url worktree_path branch
+ upstream_path=$(prepare_upstream_repo "worktree-rust" "rust worktree fixture")
+ upstream_url="file://$upstream_path"
+ branch="featA"
+
+ if git show-ref --verify --quiet "refs/heads/$branch"; then
+ git branch -f "$branch" >/dev/null
+ else
+ git branch "$branch" >/dev/null
+ fi
+ worktree_path="${SANDBOX}-${branch}"
+ cleanup_worktree_dir "$worktree_path"
+ git worktree add "$worktree_path" "$branch" >/dev/null
+
+ pushd "$worktree_path" >/dev/null
+ crossdir="$(compute_crossdir)"
+ metadata="$crossdir/metadata.json"
+ CROSSDIR="$crossdir" METADATA="$metadata" "$RUST_BIN" use repo1 "$upstream_url"
+ CROSSDIR="$crossdir" METADATA="$metadata" "$RUST_BIN" patch repo1:src/lib vendor/worktree-lib
+
+ if [ ! -f "vendor/worktree-lib/lib.txt" ]; then
+ log_error "Rust implementation: vendor/worktree-lib/lib.txt missing"
+ exit 1
+ fi
+
+ if ! grep -q "rust worktree fixture" "vendor/worktree-lib/lib.txt"; then
+ log_error "Rust implementation: lib.txt content mismatch"
+ exit 1
+ fi
+
+ if ! grep -q "cross patch repo1:main:src/lib vendor/worktree-lib" Crossfile; then
+ log_error "Rust implementation: Crossfile missing patch entry"
+ exit 1
+ fi
+
+ assert_cross_metadata_present "Rust implementation"
+ popd >/dev/null
+
+ cleanup_worktree_dir "$worktree_path"
+ log_success "Rust implementation handled git worktree patch"
+ cd "$REPO_ROOT"
+}
+
+run_just_patch_in_worktree
+run_go_patch_in_worktree
+run_rust_patch_in_worktree
+
+log_header "019_patch_worktree completed"
+rm -rf "$TEST_BASE"