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 @@ [![CI](https://github.com/epcim/git-cross/workflows/CI/badge.svg)](https://github.com/epcim/git-cross/actions/workflows/ci.yml) [![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](https://opensource.org/licenses/MIT) -[![Version](https://img.shields.io/badge/version-0.2.1-blue.svg)](https://github.com/epcim/git-cross/blob/main/CHANGELOG.md) +[![Version](https://img.shields.io/badge/version-0.2.2-blue.svg)](https://github.com/epcim/git-cross/blob/main/CHANGELOG.md) **Git's CRISPR.** Minimalist approach for mixing "parts" of git repositories using `git worktree` + `rsync`. @@ -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"