diff --git a/Justfile.cross b/Justfile.cross index 6d4254fc8..4d1affb9e 100644 --- a/Justfile.cross +++ b/Justfile.cross @@ -490,10 +490,40 @@ push path="" branch="" force="false" yes="false" message="": check-initialized list: check-deps #!/usr/bin/env fish pushd "{{REPO_DIR}}" >/dev/null - if test -d .git - just cross _log info "Configured Remotes:" - git remote -v | awk '{printf "%-20s %-50s %s\n", $1, $2, $3}' - echo "" + if test -f .git/cross/metadata.json + # Get unique remotes used by patches + 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:" + printf "%-20s %s\n" "NAME" "URL" + printf "%s\n" (string repeat -n 70 "-") + + # Build grep pattern for used remotes + set pattern (string join "|" $used_remotes) + + # Get remotes, filter by used, deduplicate fetch/push + git remote -v | grep -E "^($pattern)\s" | awk ' + { + name=$1; url=$2; type=$3 + if (!(name in seen)) { + seen[name] = url + types[name] = type + } else if (seen[name] != url) { + # Different fetch/push URLs + printf "%-20s %s\n", name, seen[name] " " types[name] + printf "%-20s %s\n", name, url " " type + delete seen[name] + } + } + END { + for (name in seen) { + printf "%-20s %s\n", name, seen[name] + } + } + ' + echo "" + end end if not test -f Crossfile diff --git a/README.md b/README.md index 998e78bb9..54f38b11a 100644 --- a/README.md +++ b/README.md @@ -16,9 +16,16 @@ | **Upstream sync** | ✅ Bidirectional | ⚠️ Complex | ⚠️ Merge commits | | **Commit visibility** | ✅ In main repo | ❌ Separate | ✅ In main repo | | **Reproducibility** | ✅ Crossfile | ⚠️ .gitmodules | ⚠️ Manual | -| **Native CLI** | ✅ Go (Primary) | ❌ N/A | ❌ Bash | -## Implementation Note +## What it is not + +Git-cross is not a replacement for `git-subrepo` or `git-submodule`. -- It provides an alternative approach and simplifies otherwise manual and complex git workflow behind into intuitive commands. + +Git-cross does not directly link external repositories to your main repository. -- It provides separate worktrees for each upstream patch, and help with sync to local repository. + +## Implementation status + +The project is still in early days and Work In Progress. Just/Golang versions are tested by the author on best-effort basis. Most of the commands and structure of "Crossfile" is already freezed. The project provides three implementations, with **Go being the primary native version for production use.** diff --git a/TODO.md b/TODO.md index 5d2c33b0f..c47720f63 100644 --- a/TODO.md +++ b/TODO.md @@ -33,8 +33,10 @@ - [x] `cross list` comand shall either print all cross remote repositories (REMOTE (alias), GIT URL) in separate table above the table with patches. Or directly inline with each patch. - [x] Implement `cross remove` patch, to remove local_pathch patch and it's worktree. Finally clean up the Metadata an Crossfile. Once physically removed, `git worktree prune` will clenaup git itself. -- [ ] Implement `cross cut` to remove git remote repo registration from "cross use" command and ask user whether either remove all patches (like: cross remove) +- [ ] Implement `cross prune [remote name]` to remove git remote repo registration from "cross use" command and ask user whether either remove all git remotes without active cross patches (like after: cross remove), then `git worktree prune` to remove all worktrees. optional argument (an remote repo alias/name would enforce either removal of all it's patches altogther with worktrees and remotes) - [x] Re-implement `wt` (worktree) command in Go and Rust with full test coverage (align logic with Justfile). +- [ ] Refactor `cross cd` to target local patched folder and output path (no subshell), supporting fzf. +- [ ] Review and propose implementation (tool and test) to be able patch even single file. If not easily possible without major refactoring, then evaluate new command "patch-file". - [ ] Improve interactive `fzf` selection in native implementations. ## Known Issues (To FIX) diff --git a/implementation-plan-release.md b/implementation-plan-release.md new file mode 100644 index 000000000..e5011f0e2 --- /dev/null +++ b/implementation-plan-release.md @@ -0,0 +1,51 @@ +# Implementation Plan - GitHub Releases & Architecture Refinement + +## Context +The `git-cross` project currently has three implementations: +1. **Go:** A native implementation (Primary focus). +2. **Shell/Justfile:** The original functional version. +3. **Rust:** A native implementation (WIP). + +To streamline distribution and maintenance, we are formalizing the preference for the Go implementation while maintaining the others for reference or future development. + +## Architecture Decision Record (ADR) - Primary Implementation Choice +**Decision: Prioritize Go as the primary native implementation for `git-cross`.** + +### Rationale: +- **Ecosystem:** Go has mature, high-level wrappers for both Git (`git-module`) and Rsync (`grsync`) that align well with our "wrapper" philosophy. +- **Distribution:** Go's static linking and cross-compilation simplicity make it ideal for a developer tool that needs to run in various environments (Mac, Linux, CI). +- **Maintenance:** The Go implementation is currently more complete and matches the behavioral requirements of the PoC with less boilerplate than the current Rust approach. + +### Consequences: +1. **Rust Implementation:** Will be marked as **Work In Progress (WIP)** and experimental. Future feature development will land in Go first. +2. **Builds & Releases:** Focus on providing pre-built binaries for Go across platforms (Linux amd64/arm64, Darwin amd64/arm64). Rust binaries will be built but marked as experimental. + +## Proposed Changes + +### 1. Documentation & Status Updates +- **`README.md`**: Update the "Implementation Note" to clearly state Go is the primary version and Rust is WIP. +- **`src-rust/src/main.rs`**: Add a WIP warning to the CLI help description. +- **`src-rust/Cargo.toml`**: Update metadata if needed. + +### 2. GitHub Release Workflow Refinement +- Update `.github/workflows/release.yml` to: + - Build Go binaries using `goreleaser` (or a similar action). + - Build Rust binaries for standard platforms. + - Attach all binaries to the GitHub Release. + - Use `softprops/action-gh-release` instead of the deprecated `actions/create-release`. + +### 3. Implementation Details for Release Workflow +#### Go Release (via GoReleaser): +Create a `.goreleaser.yaml` in `src-go/` (or root) to handle: +- Binaries: `git-cross` (from Go). +- Platforms: `linux/amd64`, `linux/arm64`, `darwin/amd64`, `darwin/arm64`. + +#### Rust Release: +- Use `cross-rs` or simple `cargo build --release` in a matrix for Rust. + +## Tasks +- [ ] Update `README.md` status section. +- [ ] Add WIP warning to Rust CLI. +- [ ] Create `.goreleaser.yaml`. +- [ ] Rewrite `.github/workflows/release.yml`. +- [ ] Update `TODO.md` to reflect these documentation and release tasks. diff --git a/src-go/main.go b/src-go/main.go index 39ec87766..ebe0e8619 100644 --- a/src-go/main.go +++ b/src-go/main.go @@ -631,19 +631,59 @@ func main() { Use: "list", Short: "Show all configured patches and remotes", RunE: func(cmd *cobra.Command, args []string) error { + meta, _ := loadMetadata() + + // Collect unique remote names from patches + usedRemotes := make(map[string]bool) + for _, p := range meta.Patches { + usedRemotes[p.Remote] = true + } + repo, err := git.Open(".") - if err == nil { + if err == nil && len(usedRemotes) > 0 { remotes, _ := git.NewCommand("remote", "-v").RunInDir(repo.Path()) if len(remotes) > 0 { logInfo("Configured Remotes:") remotesStr := strings.TrimSpace(string(remotes)) lines := strings.Split(remotesStr, "\n") - table := tablewriter.NewWriter(os.Stdout) - table.Header("NAME", "URL", "TYPE") + + // Map to track fetch/push URLs per remote for deduplication + type remoteInfo struct { + fetch string + push string + } + remoteMap := make(map[string]*remoteInfo) + for _, line := range lines { fields := strings.Fields(line) if len(fields) >= 3 { - table.Append(fields[0], fields[1], fields[2]) + name := fields[0] + url := fields[1] + rtype := fields[2] // (fetch) or (push) + + if !usedRemotes[name] { + continue + } + + if remoteMap[name] == nil { + remoteMap[name] = &remoteInfo{} + } + if strings.Contains(rtype, "fetch") { + remoteMap[name].fetch = url + } else if strings.Contains(rtype, "push") { + remoteMap[name].push = url + } + } + } + + table := tablewriter.NewWriter(os.Stdout) + table.Header("NAME", "URL") + for name, info := range remoteMap { + if info.fetch == info.push || info.push == "" { + table.Append(name, info.fetch) + } else { + table.Append(name, info.fetch+" (fetch)") + table.Append(name, info.push+" (push)") } } table.Render() @@ -651,7 +691,6 @@ func main() { } } - meta, _ := loadMetadata() if len(meta.Patches) == 0 { fmt.Println("No patches configured.") return nil diff --git a/src-rust/src/main.rs b/src-rust/src/main.rs index 39e560dca..740d8bb3b 100644 --- a/src-rust/src/main.rs +++ b/src-rust/src/main.rs @@ -726,37 +726,70 @@ fn main() -> Result<()> { } } Commands::List => { - if let Ok(remotes) = run_cmd(&["git", "remote", "-v"]) { - if !remotes.is_empty() { - log_info("Configured Remotes:"); - #[derive(Tabled)] - struct RemoteRow { - name: String, - url: String, - #[tabled(rename = "type")] - rtype: String, - } - let rows: Vec = remotes - .lines() - .filter_map(|line| { + let metadata = load_metadata()?; + + // Collect unique remote names from patches + let used_remotes: std::collections::HashSet = + metadata.patches.iter().map(|p| p.remote.clone()).collect(); + + if !used_remotes.is_empty() { + if let Ok(remotes) = run_cmd(&["git", "remote", "-v"]) { + if !remotes.is_empty() { + log_info("Configured Remotes:"); + + // Map to track fetch/push URLs per remote for deduplication + let mut remote_map: std::collections::HashMap = + std::collections::HashMap::new(); + + for line in remotes.lines() { let fields: Vec<&str> = line.split_whitespace().collect(); if fields.len() >= 3 { - Some(RemoteRow { - name: fields[0].to_string(), - url: fields[1].to_string(), - rtype: fields[2].to_string(), - }) + let name = fields[0]; + let url = fields[1]; + let rtype = fields[2]; + + if !used_remotes.contains(name) { + continue; + } + + 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") { + entry.1 = url.to_string(); + } + } + } + + #[derive(Tabled)] + struct RemoteRow { + name: String, + url: String, + } + let mut rows: Vec = Vec::new(); + for (name, (fetch, push)) in &remote_map { + if fetch == push || push.is_empty() { + rows.push(RemoteRow { + name: name.clone(), + url: fetch.clone(), + }); } else { - None + rows.push(RemoteRow { + name: name.clone(), + url: format!("{} (fetch)", fetch), + }); + rows.push(RemoteRow { + name: name.clone(), + url: format!("{} (push)", push), + }); } - }) - .collect(); - println!("{}", Table::new(rows)); - println!(); + } + println!("{}", Table::new(rows)); + println!(); + } } } - let metadata = load_metadata()?; if metadata.patches.is_empty() { println!("No patches configured."); } else { diff --git a/test/014_remove.sh b/test/014_remove.sh index 92f2b5ad0..66504d4e8 100755 --- a/test/014_remove.sh +++ b/test/014_remove.sh @@ -39,11 +39,13 @@ if [ -d "vendor/app-rust" ]; then fail "vendor/app-rust still exists after remov if grep -q "vendor/app-rust" Crossfile; then fail "Crossfile still contains patch entry"; fi if grep -q "vendor/app-rust" .git/cross/metadata.json; then fail "Metadata still contains patch entry"; fi -# 4. Test list command (Go) +# 4. Test list command (Go) - need active patch for remotes to show echo "## Testing 'list' command (Go)..." +just cross patch repo1:src/lib vendor/list-test list_output=$("$REPO_ROOT/src-go/git-cross-go" list) if ! echo "$list_output" | grep -q "Configured Remotes"; then fail "Go list missing Remotes section"; fi if ! echo "$list_output" | grep -q "repo1"; then fail "Go list missing repo1 remote"; fi +just cross remove vendor/list-test # 5. Test Crossfile deduplication (Go) echo "## Testing Crossfile deduplication (Go)..."