From 4eaae514370512fad82b1e9cbdd0a5faea5ee1fb Mon Sep 17 00:00:00 2001
From: "p.michalec"
Date: Wed, 7 Jan 2026 20:19:02 +0100
Subject: [PATCH 1/2] feat: Add relative path resolution for diff/status
commands and fix test suite
- Implement relative path resolution for git cross diff and status
- Support ., .., relative, and absolute paths
- Resolve symlinks and convert to repo-relative paths
- Fix worktree path resolution bug (always join with repo root)
- Fix status command conflict detection
- Check for conflicts in both worktree and local paths
- Add git ls-files -u check for local working directory
- Comprehensive test suite improvements
- test/003: Add 5 test cases for relative path resolution
- test/007: Fix stash conflict cleanup after sync
- test/008: Handle Rust CLI output format variations
- test/010: Add skip logic for Justfile implementation
- test/015: Complete rewrite with 3 prune test scenarios
All three implementations (Justfile, Go, Rust) now support:
- cd vendor/lib && git cross diff .
- git cross diff ../sibling
- git cross diff /absolute/path
- git cross status
Technical fix: Metadata paths are repo-relative, so we must always
join them with repo root before resolving, regardless of CWD.
Added SESSION_SUMMARY.md documenting the complete implementation.
---
Justfile.cross | 33 +++++-
SESSION_SUMMARY.md | 187 ++++++++++++++++++++++++++++++
src-go/main.go | 120 +++++++++++++++++--
src-rust/src/main.rs | 116 +++++++++++++++++--
test/003_diff.sh | 73 ++++++++++++
test/007_status.sh | 18 +++
test/008_rust_cli.sh | 10 +-
test/010_worktree.sh | 7 ++
test/015_prune.sh | 267 +++++++++++++++----------------------------
9 files changed, 630 insertions(+), 201 deletions(-)
create mode 100644 SESSION_SUMMARY.md
diff --git a/Justfile.cross b/Justfile.cross
index 21ed46c7a..61cc8708b 100644
--- a/Justfile.cross
+++ b/Justfile.cross
@@ -577,10 +577,32 @@ sync *path="": check-initialized
[no-cd]
diff path="": check-initialized
#!/usr/bin/env fish
+
+ set resolved_path "{{path}}"
+
+ # If path provided, resolve to repo-relative
+ if test -n "$resolved_path"
+ pushd "{{REPO_DIR}}" >/dev/null
+ # Check if path is a directory or file
+ if test -e "$resolved_path"
+ # cd to the path (or its directory) and get repo-relative location
+ if test -d "$resolved_path"
+ set resolved_path (cd "$resolved_path" && git rev-parse --show-prefix | sed 's,/$,,')
+ else
+ # Handle file path - get directory
+ set dir (dirname "$resolved_path")
+ 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"
+ exit 1
+ end
+ popd >/dev/null
+ end
# Query metadata.json
- just cross _resolve_context2 "{{path}}" | source \
- || { just cross _log error "Error: Could not resolve metadata for '$path'."; exit 1; }
+ just cross _resolve_context2 "$resolved_path" | source \
+ || { just cross _log error "Error: Could not resolve metadata for '$resolved_path'."; exit 1; }
pushd "{{REPO_DIR}}"
if test -d $worktree
@@ -850,10 +872,15 @@ status: check-deps
set upstream_stat "$ahead ahead"
end
- # Check conflicts
+ # Check conflicts in worktree
if git -C $wt 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"
+ end
else
set diff_stat "Missing WT"
end
diff --git a/SESSION_SUMMARY.md b/SESSION_SUMMARY.md
new file mode 100644
index 000000000..188cdfbdb
--- /dev/null
+++ b/SESSION_SUMMARY.md
@@ -0,0 +1,187 @@
+# Session Summary - Relative Path Resolution & Test Fixes
+
+## Date
+January 7, 2026
+
+## Overview
+Implemented relative path resolution for `git cross diff` command and fixed multiple test issues across all three implementations (Justfile, Go, Rust).
+
+## What Was Completed
+
+### 1. Relative Path Resolution Feature
+**Problem**: Users could only run `git cross diff` from repo root with repo-relative paths. Commands like `cd vendor/lib && git cross diff .` would fail.
+
+**Solution**: Implemented path resolution logic that:
+- Handles relative paths (`.`, `..`, `./subdir`)
+- Handles absolute paths
+- Resolves symlinks properly
+- Converts to repo-relative paths for metadata matching
+
+**Files Modified**:
+- `Justfile.cross` (lines 577-610): Added git-based path resolution
+- `src-go/main.go`:
+ - Added `resolvePathToRepoRelative()` function (lines 306-368)
+ - Fixed `diffCmd` (lines 1054-1103)
+ - Fixed `statusCmd` (lines 1004-1060)
+- `src-rust/src/main.rs`:
+ - Added `resolve_path_to_repo_relative()` function
+ - Fixed `Commands::Diff` handler (lines 1333-1360)
+ - Fixed `Commands::Status` handler (lines 1090-1180)
+
+**Key Technical Fix**: Always join worktree and local paths with repo root:
+```go
+worktreePath := filepath.Join(root, p.Worktree)
+localPath := filepath.Join(root, p.LocalPath)
+```
+
+### 2. Status Command Conflict Detection Fix
+**Problem**: Status command only checked worktree for conflicts, missing conflicts in local working directory.
+
+**Solution**: Added check for `git ls-files -u` in local path for all implementations.
+
+**Files Modified**:
+- `Justfile.cross` (lines 875-882)
+- `src-go/main.go` (lines 1052-1059)
+- `src-rust/src/main.rs` (lines 1171-1178)
+
+### 3. Test Suite Fixes
+
+#### test/003_diff.sh ✅
+**Added**: 5 comprehensive test cases for relative path resolution:
+1. Basic diff from repo root
+2. Diff from subdirectory using `.`
+3. Diff using `../` relative path
+4. Diff with absolute path
+5. Diff with modified files
+
+#### test/007_status.sh ✅
+**Fixed**: Stash conflict cleanup (lines 80-96)
+- Added conflict resolution after sync
+- Force resync to ensure clean state
+- Properly clean up stash
+
+#### test/008_rust_cli.sh ✅
+**Fixed**: Output pattern matching (lines 84-93)
+- Accept both "opening" and "exec" in output
+- Handle ANSI color codes in Rust output
+
+#### test/010_worktree.sh ✅
+**Fixed**: Skip logic for Justfile implementation (lines 9-12)
+- Commands only exist in Go/Rust
+- Test correctly skips for Justfile
+
+#### test/015_prune.sh ✅
+**Fixed**: Complete rewrite to actually test prune functionality
+- Test 1: Prune specific remote with patches
+- Test 2: Setup validation for interactive prune
+- Test 3: Verify worktree pruning
+- Uses proper `setup_sandbox()` from common.sh
+
+### 4. Documentation
+**Created**:
+- `DIFF_RELATIVE_PATHS.md`: Detailed feature documentation
+- `SESSION_SUMMARY.md`: This summary
+
+## Test Results
+All modified tests now pass:
+```
+✅ test/003_diff.sh - Relative path resolution
+✅ 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
+```
+
+## Files Changed (Ready for Commit)
+```
+modified: Justfile.cross
+modified: src-go/main.go
+modified: src-rust/src/main.go
+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
+```
+
+## Untracked Files (Can be ignored)
+```
+DIFF_RELATIVE_PATHS.md (optional documentation)
+SESSION_SUMMARY.md (this file)
+claude-code-proxy/ (development artifact)
+debug-sparse/ (test artifact)
+test-sparse/ (test artifact)
+src-go/git-cross (binary - should be in .gitignore)
+```
+
+## Next Steps
+
+### 1. Rebuild Binaries (Optional - for manual testing)
+```bash
+cd src-go && go build -o git-cross-go main.go
+cd ../src-rust && cargo build --release
+```
+
+### 2. Commit Changes
+```bash
+git add Justfile.cross src-go/main.go src-rust/src/main.rs test/*.sh
+git commit -m "feat: Add relative path resolution for diff/status commands and fix test suite
+
+- Implement relative path resolution for git cross diff and status
+ - Support ., .., relative, and absolute paths
+ - Resolve symlinks and convert to repo-relative paths
+ - Fix worktree path resolution bug
+
+- Fix status command conflict detection
+ - Check for conflicts in both worktree and local paths
+ - Add git ls-files -u check for local working directory
+
+- Comprehensive test suite improvements
+ - test/003: Add 5 test cases for relative path resolution
+ - 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
+
+All three implementations (Justfile, Go, Rust) now support:
+- cd vendor/lib && git cross diff .
+- git cross diff ../sibling
+- git cross diff /absolute/path
+- git cross status
+
+Closes #XX (if there's an issue)"
+```
+
+### 3. Push Changes
+```bash
+git push origin master
+```
+
+## Technical Notes
+
+### Path Resolution Algorithm
+1. Get current working directory
+2. Resolve input path to absolute (handling `.`, `..`, symlinks)
+3. Get repository root using `git rev-parse --show-toplevel`
+4. Calculate relative path from root to target
+5. Clean and normalize path
+6. Match against metadata entries
+
+### Bug Root Cause
+Metadata stores paths relative to repo root, but when CWD is in a subdirectory, relative paths from metadata don't resolve correctly. Solution: Always join metadata paths with repo root before any file operations.
+
+### Test Philosophy Applied
+- All tests use `setup_sandbox()` for isolation
+- Tests skip gracefully when features unavailable
+- Conflicts auto-resolved when reasonable
+- Comprehensive coverage for new features
+- Exit codes properly handled
+
+## Impact
+- **User Experience**: Users can now work naturally from any directory
+- **Consistency**: All three implementations behave identically
+- **Test Quality**: More robust and comprehensive test coverage
+- **Maintainability**: Clear patterns for path handling established
+
+## Credits
+Session conducted with OpenCode AI assistant on January 7, 2026.
diff --git a/src-go/main.go b/src-go/main.go
index 9544d5ba5..b8d1a0918 100644
--- a/src-go/main.go
+++ b/src-go/main.go
@@ -303,6 +303,72 @@ func findPatchForPath(meta Metadata, rel string) *Patch {
return selected
}
+// resolvePathToRepoRelative converts any path (relative, absolute, or repo-relative)
+// to a repo-relative path for matching against metadata
+func resolvePathToRepoRelative(inputPath string) (string, error) {
+ if inputPath == "" {
+ return "", nil
+ }
+
+ // Get repo root
+ root, err := getRepoRoot()
+ if err != nil {
+ return "", err
+ }
+
+ // Get current working directory
+ cwd, err := os.Getwd()
+ if err != nil {
+ return "", err
+ }
+
+ // Resolve input path to absolute
+ var absPath string
+ if filepath.IsAbs(inputPath) {
+ absPath = inputPath
+ } else {
+ absPath = filepath.Join(cwd, inputPath)
+ }
+
+ // Clean the path (resolves . and ..)
+ absPath = filepath.Clean(absPath)
+
+ // Evaluate symlinks to handle /tmp -> /private/tmp on macOS
+ absPath, err = filepath.EvalSymlinks(absPath)
+ if err != nil {
+ // If EvalSymlinks fails (path doesn't exist), continue with cleaned path
+ // This allows diff to work even if path doesn't exist yet
+ absPath = filepath.Clean(absPath)
+ }
+
+ // Also evaluate symlinks for root to ensure consistent comparison
+ rootResolved, err := filepath.EvalSymlinks(root)
+ if err == nil {
+ root = rootResolved
+ }
+
+ // Get relative path from repo root
+ relPath, err := filepath.Rel(root, absPath)
+ if err != nil {
+ return "", err
+ }
+
+ // Convert to forward slashes for consistency
+ relPath = filepath.ToSlash(relPath)
+
+ // If the path is outside the repo, return error
+ if strings.HasPrefix(relPath, "..") {
+ return "", fmt.Errorf("path is outside repository: %s", inputPath)
+ }
+
+ // Normalize: remove . and trim slashes
+ if relPath == "." {
+ return "", nil
+ }
+
+ return strings.Trim(relPath, "/"), nil
+}
+
func selectPatchInteractive(meta *Metadata) (*Patch, error) {
if _, err := exec.LookPath("fzf"); err != nil {
return nil, err
@@ -944,6 +1010,13 @@ Without path: uses fzf to select a patch, then copies the path to clipboard.`,
fmt.Println("No patches configured.")
return nil
}
+
+ // Get repo root for resolving relative paths
+ root, err := getRepoRoot()
+ if err != nil {
+ return fmt.Errorf("failed to get repo root: %w", err)
+ }
+
table := tablewriter.NewWriter(os.Stdout)
table.Header("LOCAL PATH", "DIFF", "UPSTREAM", "CONFLICTS")
for _, p := range meta.Patches {
@@ -951,17 +1024,21 @@ Without path: uses fzf to select a patch, then copies the path to clipboard.`,
upstream := "Synced"
conflicts := "No"
- if _, err := os.Stat(p.Worktree); os.IsNotExist(err) {
+ // Resolve worktree path relative to repo root
+ worktreePath := filepath.Join(root, p.Worktree)
+ if _, err := os.Stat(worktreePath); os.IsNotExist(err) {
diff = "Missing WT"
} else {
- // Quick diff check
- c := exec.Command("git", "diff", "--no-index", "--quiet", p.Worktree+"/"+p.RemotePath, p.LocalPath)
+ // Quick diff check - use absolute paths
+ upstreamPath := filepath.Join(worktreePath, p.RemotePath)
+ localPath := filepath.Join(root, p.LocalPath)
+ c := exec.Command("git", "diff", "--no-index", "--quiet", upstreamPath, localPath)
if err := c.Run(); err != nil {
diff = "Modified"
}
- behindOut, _ := git.NewCommand("rev-list", "--count", "HEAD..@{upstream}").RunInDir(p.Worktree)
- aheadOut, _ := git.NewCommand("rev-list", "--count", "@{upstream}..HEAD").RunInDir(p.Worktree)
+ behindOut, _ := git.NewCommand("rev-list", "--count", "HEAD..@{upstream}").RunInDir(worktreePath)
+ aheadOut, _ := git.NewCommand("rev-list", "--count", "@{upstream}..HEAD").RunInDir(worktreePath)
behind := strings.TrimSpace(string(behindOut))
ahead := strings.TrimSpace(string(aheadOut))
@@ -972,7 +1049,13 @@ Without path: uses fzf to select a patch, then copies the path to clipboard.`,
upstream = ahead + " ahead"
}
- if out, _ := git.NewCommand("ls-files", "-u").RunInDir(p.Worktree); len(out) > 0 {
+ if out, _ := git.NewCommand("ls-files", "-u").RunInDir(worktreePath); len(out) > 0 {
+ conflicts = "YES"
+ }
+
+ // Also check conflicts in local path (from failed stash restore)
+ localAbsPath := filepath.Join(root, p.LocalPath)
+ if out, _ := git.NewCommand("ls-files", "-u", localAbsPath).RunInDir(root); len(out) > 0 {
conflicts = "YES"
}
}
@@ -988,8 +1071,20 @@ Without path: uses fzf to select a patch, then copies the path to clipboard.`,
RunE: func(cmd *cobra.Command, args []string) error {
path := ""
if len(args) > 0 {
- path = args[0]
+ // Resolve relative/absolute path to repo-relative
+ resolved, err := resolvePathToRepoRelative(args[0])
+ if err != nil {
+ return fmt.Errorf("failed to resolve path: %w", err)
+ }
+ path = resolved
}
+
+ // Get repo root for resolving relative paths in metadata
+ root, err := getRepoRoot()
+ if err != nil {
+ return fmt.Errorf("failed to get repo root: %w", err)
+ }
+
meta, _ := loadMetadata()
found := false
for _, p := range meta.Patches {
@@ -997,12 +1092,19 @@ Without path: uses fzf to select a patch, then copies the path to clipboard.`,
continue
}
found = true
- if _, err := os.Stat(p.Worktree); os.IsNotExist(err) {
+
+ // Resolve worktree path relative to repo root
+ worktreePath := filepath.Join(root, p.Worktree)
+ if _, err := os.Stat(worktreePath); os.IsNotExist(err) {
logError(fmt.Sprintf("Worktree not found for %s", p.LocalPath))
continue
}
+
// Use git diff --no-index to compare directories
- c := exec.Command("git", "diff", "--no-index", filepath.Join(p.Worktree, p.RemotePath), p.LocalPath)
+ // Both paths must be resolved relative to repo root
+ upstreamPath := filepath.Join(worktreePath, p.RemotePath)
+ localPath := filepath.Join(root, p.LocalPath)
+ c := exec.Command("git", "diff", "--no-index", upstreamPath, localPath)
c.Stdout = os.Stdout
c.Stderr = os.Stderr
// git diff --no-index returns 1 if there are differences, which cobra might treat as error
diff --git a/src-rust/src/main.rs b/src-rust/src/main.rs
index 370663ab5..4ea759a66 100644
--- a/src-rust/src/main.rs
+++ b/src-rust/src/main.rs
@@ -530,6 +530,64 @@ fn repo_relative_path() -> Result