Skip to content

Commit 8b2ed8b

Browse files
committed
feat(prune): implement cross prune command across all implementations
Add 'cross prune' command to clean up unused remotes and worktrees: - Justfile.cross (lines 230-303): Interactive prune with confirmation - Go (src-go/main.go): Cobra command with full logic - Rust (src-rust/src/main.rs): Clap command with full logic - Test coverage (test/015_prune.sh): 3 comprehensive test scenarios Command behaviors: 1. 'cross prune' (no args): - Finds all remotes that have no active patches - Excludes 'origin' and 'git-cross' from cleanup - Asks for user confirmation before removal - Runs 'git worktree prune' to clean stale worktrees 2. 'cross prune <remote>' (with remote name): - Finds all patches using that remote - Removes each patch (worktree + local dir + metadata) - Removes the remote itself - Updates Crossfile and metadata.json Implementation follows agent guidelines: - ✅ All three implementations updated (Just, Go, Rust) - ✅ Command parity maintained across implementations - ✅ Test coverage added (test/015_prune.sh) - ✅ TODO.md updated with completion status Test scenarios: 1. Prune unused remotes (interactive) 2. Prune specific remote (removes all its patches) 3. Prune with no unused remotes (no-op case) Related: #P1-prune-command Closes: TODO.md P1 task Tested: Go and Rust builds succeed, Justfile syntax verified
1 parent 3141a04 commit 8b2ed8b

File tree

5 files changed

+548
-4
lines changed

5 files changed

+548
-4
lines changed

Justfile.cross

Lines changed: 76 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -227,6 +227,82 @@ remove path: check-deps
227227
just cross _log success "Patch removed successfully."
228228
popd
229229

230+
# Prune unused remotes and worktrees, or remove all patches for a specific remote
231+
prune remote_name="": check-deps
232+
#!/usr/bin/env fish
233+
set remote "{{remote_name}}"
234+
pushd "{{REPO_DIR}}"
235+
if not test -f {{METADATA}}
236+
just cross _log error "No metadata found."
237+
exit 1
238+
end
239+
240+
if test -n "$remote"
241+
# Prune specific remote: remove all its patches
242+
just cross _log info "Pruning all patches for remote: $remote..."
243+
244+
# Get all patches for this remote
245+
set patches (jq -r --arg remote "$remote" '.patches[] | select(.remote == $remote) | .local_path' {{METADATA}})
246+
247+
if test -z "$patches"
248+
just cross _log warn "No patches found for remote: $remote"
249+
else
250+
# Remove each patch
251+
for patch_path in $patches
252+
just cross _log info "Removing patch: $patch_path"
253+
just cross remove "$patch_path"
254+
end
255+
end
256+
257+
# Remove the remote itself
258+
if git remote | grep -q "^$remote\$"
259+
just cross _log info "Removing git remote: $remote"
260+
git remote remove "$remote"
261+
end
262+
263+
just cross _log success "Remote $remote and all its patches pruned successfully."
264+
else
265+
# Prune all unused remotes (no active patches)
266+
just cross _log info "Finding unused remotes..."
267+
268+
# Get all remotes used by patches
269+
set used_remotes (jq -r '.patches[].remote' {{METADATA}} | sort -u)
270+
271+
# Get all git remotes (except origin and git-cross)
272+
set all_remotes (git remote | grep -v "^origin\$" | grep -v "^git-cross\$")
273+
274+
# Find unused remotes
275+
set unused_remotes
276+
for remote in $all_remotes
277+
if not contains $remote $used_remotes
278+
set unused_remotes $unused_remotes $remote
279+
end
280+
end
281+
282+
if test -z "$unused_remotes"
283+
just cross _log info "No unused remotes found."
284+
else
285+
just cross _log info "Unused remotes: $unused_remotes"
286+
read -P "Remove these remotes? [y/N]: " confirm
287+
288+
if test "$confirm" = "y"; or test "$confirm" = "Y"
289+
for remote in $unused_remotes
290+
just cross _log info "Removing remote: $remote"
291+
git remote remove "$remote"
292+
end
293+
just cross _log success "Unused remotes removed."
294+
else
295+
just cross _log info "Pruning cancelled."
296+
end
297+
end
298+
299+
# Always prune stale worktrees
300+
just cross _log info "Pruning stale worktrees..."
301+
git worktree prune --verbose
302+
just cross _log success "Worktree pruning complete."
303+
end
304+
popd
305+
230306
# AICONTEXT: "patch" will do sparse checkout of specified branch of remote repository into local path. "remote_spec" is in format "remote_name:branch", where "branch" is optional. local_path is the same are remote_path if not provided. Command shall firs use `git ls-remote --heads ` to identify whether remote is having main or master as default branch if not provided - no more evaluation needed, simply grep "refs/heads" with regexp. Tool shall configure sparse checkout and use `git worktree add` and use `".git/cross/worktrees/$remote"_"$hash"` as working directory. Hash shall be short, but shall be created from path and branch name and humans shall ideally read it. checkout either only need maximum of 1 git history (last commmit version). When the checkout is done, "sync" target is called, to sync just this specific "patch" git worktree into main repository to local_path. Then a placeholder is required for post_sync_hook function to run. Finally call `git add` on local_path in top level repo.
231307
# AICONTEXT: for implementation, use "fish" keep it simple, shell comamnds shall be readable. Ideally keep bellow 30 lines. User interaction shall be kept minimal. Debug statements are not needed. Document in comments major logical blocks.
232308
# Patch a directory from a remote into a local path

TODO.md

Lines changed: 12 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -38,9 +38,18 @@
3838
## Future Enhancements / Backlog
3939

4040
### P1: High Priority
41-
- [ ] **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.
42-
- **Effort:** 3-4 hours
43-
- **Files:** `src-go/main.go`, `src-rust/`, `Justfile.cross`, `test/015_prune.sh`
41+
- [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.
42+
- **Effort:** 3-4 hours (completed 2025-01-06)
43+
- **Files:** `src-go/main.go`, `src-rust/src/main.rs`, `Justfile.cross`, `test/015_prune.sh`
44+
- **Implementation:**
45+
- ✅ Justfile.cross (lines 230-303): Full interactive prune with confirmation
46+
- ✅ Go (src-go/main.go): Cobra command with same logic
47+
- ✅ Rust (src-rust/src/main.rs): Clap command with same logic
48+
- ✅ Test coverage (test/015_prune.sh): 3 test scenarios
49+
- **Behavior:**
50+
- `cross prune`: Finds unused remotes, asks for confirmation, removes them, prunes stale worktrees
51+
- `cross prune <remote>`: Removes all patches for that remote, then removes the remote itself
52+
- **Status:** COMPLETE - Ready for v0.2.1 release
4453

4554
### P2: Lower Priority
4655
- [ ] **Refactor `cross cd`** - Target local patched folder and output path (no subshell), supporting fzf. Enable pattern: `cd $(cross cd <patch>)`

src-go/main.go

Lines changed: 139 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1136,7 +1136,145 @@ func main() {
11361136
},
11371137
}
11381138

1139-
rootCmd.AddCommand(useCmd, patchCmd, syncCmd, removeCmd, cdCmd, wtCmd, listCmd, statusCmd, diffCmd, replayCmd, pushCmd, execCmd, initCmd)
1139+
pruneCmd := &cobra.Command{
1140+
Use: "prune [remote]",
1141+
Short: "Prune unused remotes and worktrees, or remove all patches for a specific remote",
1142+
Args: cobra.MaximumNArgs(1),
1143+
RunE: func(cmd *cobra.Command, args []string) error {
1144+
meta, _ := loadMetadata()
1145+
1146+
if len(args) == 1 {
1147+
// Prune specific remote: remove all its patches
1148+
remoteName := args[0]
1149+
logInfo(fmt.Sprintf("Pruning all patches for remote: %s...", remoteName))
1150+
1151+
// Find all patches for this remote
1152+
var patchesToRemove []string
1153+
for _, p := range meta.Patches {
1154+
if p.Remote == remoteName {
1155+
patchesToRemove = append(patchesToRemove, p.LocalPath)
1156+
}
1157+
}
1158+
1159+
if len(patchesToRemove) == 0 {
1160+
logInfo(fmt.Sprintf("No patches found for remote: %s", remoteName))
1161+
} else {
1162+
// Remove each patch
1163+
for _, patchPath := range patchesToRemove {
1164+
logInfo(fmt.Sprintf("Removing patch: %s", patchPath))
1165+
// Call remove logic directly
1166+
localPath := filepath.Clean(patchPath)
1167+
meta, _ := loadMetadata()
1168+
var patch *Patch
1169+
patchIdx := -1
1170+
for i, p := range meta.Patches {
1171+
if p.LocalPath == localPath {
1172+
patch = &meta.Patches[i]
1173+
patchIdx = i
1174+
break
1175+
}
1176+
}
1177+
1178+
if patch != nil {
1179+
// Remove worktree
1180+
if _, err := os.Stat(patch.Worktree); err == nil {
1181+
git.NewCommand("worktree", "remove", "--force", patch.Worktree).RunInDir(".")
1182+
}
1183+
1184+
// Remove from Crossfile
1185+
path, err := getCrossfilePath()
1186+
if err == nil {
1187+
data, err := os.ReadFile(path)
1188+
if err == nil {
1189+
lines := strings.Split(string(data), "\n")
1190+
var newLines []string
1191+
for _, line := range lines {
1192+
if !strings.Contains(line, "patch") || !strings.Contains(line, localPath) {
1193+
newLines = append(newLines, line)
1194+
}
1195+
}
1196+
os.WriteFile(path, []byte(strings.Join(newLines, "\n")), 0o644)
1197+
}
1198+
}
1199+
1200+
// Remove from metadata
1201+
meta.Patches = append(meta.Patches[:patchIdx], meta.Patches[patchIdx+1:]...)
1202+
saveMetadata(meta)
1203+
1204+
// Remove local directory
1205+
os.RemoveAll(localPath)
1206+
}
1207+
}
1208+
}
1209+
1210+
// Remove the remote itself
1211+
out, _ := git.NewCommand("remote").RunInDir(".")
1212+
remotes := strings.Split(strings.TrimSpace(string(out)), "\n")
1213+
for _, r := range remotes {
1214+
if strings.TrimSpace(r) == remoteName {
1215+
logInfo(fmt.Sprintf("Removing git remote: %s", remoteName))
1216+
git.NewCommand("remote", "remove", remoteName).RunInDir(".")
1217+
break
1218+
}
1219+
}
1220+
1221+
logSuccess(fmt.Sprintf("Remote %s and all its patches pruned successfully.", remoteName))
1222+
} else {
1223+
// Prune all unused remotes (no active patches)
1224+
logInfo("Finding unused remotes...")
1225+
1226+
// Get all remotes used by patches
1227+
usedRemotes := make(map[string]bool)
1228+
for _, p := range meta.Patches {
1229+
usedRemotes[p.Remote] = true
1230+
}
1231+
1232+
// Get all git remotes
1233+
out, _ := git.NewCommand("remote").RunInDir(".")
1234+
allRemotes := strings.Split(strings.TrimSpace(string(out)), "\n")
1235+
1236+
// Find unused remotes (excluding origin and git-cross)
1237+
var unusedRemotes []string
1238+
for _, remote := range allRemotes {
1239+
remote = strings.TrimSpace(remote)
1240+
if remote == "" || remote == "origin" || remote == "git-cross" {
1241+
continue
1242+
}
1243+
if !usedRemotes[remote] {
1244+
unusedRemotes = append(unusedRemotes, remote)
1245+
}
1246+
}
1247+
1248+
if len(unusedRemotes) == 0 {
1249+
logInfo("No unused remotes found.")
1250+
} else {
1251+
logInfo(fmt.Sprintf("Unused remotes: %s", strings.Join(unusedRemotes, ", ")))
1252+
fmt.Print("Remove these remotes? [y/N]: ")
1253+
var confirm string
1254+
fmt.Scanln(&confirm)
1255+
1256+
if confirm == "y" || confirm == "Y" {
1257+
for _, remote := range unusedRemotes {
1258+
logInfo(fmt.Sprintf("Removing remote: %s", remote))
1259+
git.NewCommand("remote", "remove", remote).RunInDir(".")
1260+
}
1261+
logSuccess("Unused remotes removed.")
1262+
} else {
1263+
logInfo("Pruning cancelled.")
1264+
}
1265+
}
1266+
1267+
// Always prune stale worktrees
1268+
logInfo("Pruning stale worktrees...")
1269+
git.NewCommand("worktree", "prune", "--verbose").RunInDir(".")
1270+
logSuccess("Worktree pruning complete.")
1271+
}
1272+
1273+
return nil
1274+
},
1275+
}
1276+
1277+
rootCmd.AddCommand(useCmd, patchCmd, syncCmd, removeCmd, pruneCmd, cdCmd, wtCmd, listCmd, statusCmd, diffCmd, replayCmd, pushCmd, execCmd, initCmd)
11401278
if err := rootCmd.Execute(); err != nil {
11411279
os.Exit(1)
11421280
}

src-rust/src/main.rs

Lines changed: 115 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -67,6 +67,11 @@ enum Commands {
6767
/// Local path of the patch to remove
6868
path: String,
6969
},
70+
/// Prune unused remotes and worktrees, or remove all patches for a specific remote
71+
Prune {
72+
/// Optional remote name to prune all its patches
73+
remote: Option<String>,
74+
},
7075
/// Push changes back to upstream
7176
Push {
7277
#[arg(default_value = "")]
@@ -1076,6 +1081,116 @@ fn main() -> Result<()> {
10761081

10771082
log_success("Patch removed successfully.");
10781083
}
1084+
Commands::Prune { remote } => {
1085+
let mut metadata = load_metadata()?;
1086+
1087+
if let Some(remote_name) = remote {
1088+
// Prune specific remote: remove all its patches
1089+
log_info(&format!("Pruning all patches for remote: {}...", remote_name));
1090+
1091+
// Find all patches for this remote
1092+
let patches_to_remove: Vec<Patch> = metadata
1093+
.patches
1094+
.iter()
1095+
.filter(|p| p.remote == *remote_name)
1096+
.cloned()
1097+
.collect();
1098+
1099+
if patches_to_remove.is_empty() {
1100+
log_info(&format!("No patches found for remote: {}", remote_name));
1101+
} else {
1102+
// Remove each patch
1103+
for patch in patches_to_remove {
1104+
log_info(&format!("Removing patch: {}", patch.local_path));
1105+
1106+
// Remove worktree
1107+
if Path::new(&patch.worktree).exists() {
1108+
let _ = run_cmd(&["git", "worktree", "remove", "--force", &patch.worktree]);
1109+
}
1110+
1111+
// Remove from Crossfile
1112+
if let Ok(cross_path) = get_crossfile_path() {
1113+
if let Ok(content) = fs::read_to_string(&cross_path) {
1114+
let lines: Vec<String> = content
1115+
.lines()
1116+
.filter(|l| !l.contains("patch") || !l.contains(&patch.local_path))
1117+
.map(|l| l.to_string())
1118+
.collect();
1119+
let mut new_content = lines.join("\n");
1120+
if !new_content.is_empty() {
1121+
new_content.push('\n');
1122+
}
1123+
let _ = fs::write(&cross_path, new_content);
1124+
}
1125+
}
1126+
1127+
// Remove from metadata
1128+
metadata.patches.retain(|p| p.local_path != patch.local_path);
1129+
1130+
// Remove local directory
1131+
let _ = fs::remove_dir_all(&patch.local_path);
1132+
}
1133+
save_metadata(&metadata)?;
1134+
}
1135+
1136+
// Remove the remote itself
1137+
if let Ok(remotes) = run_cmd(&["git", "remote"]) {
1138+
if remotes.lines().any(|r| r.trim() == remote_name) {
1139+
log_info(&format!("Removing git remote: {}", remote_name));
1140+
let _ = run_cmd(&["git", "remote", "remove", remote_name]);
1141+
}
1142+
}
1143+
1144+
log_success(&format!("Remote {} and all its patches pruned successfully.", remote_name));
1145+
} else {
1146+
// Prune all unused remotes (no active patches)
1147+
log_info("Finding unused remotes...");
1148+
1149+
// Get all remotes used by patches
1150+
let used_remotes: std::collections::HashSet<String> =
1151+
metadata.patches.iter().map(|p| p.remote.clone()).collect();
1152+
1153+
// Get all git remotes
1154+
let all_remotes = run_cmd(&["git", "remote"])?;
1155+
let all_remotes: Vec<String> = all_remotes
1156+
.lines()
1157+
.map(|s| s.trim().to_string())
1158+
.filter(|r| !r.is_empty() && r != "origin" && r != "git-cross")
1159+
.collect();
1160+
1161+
// Find unused remotes
1162+
let unused_remotes: Vec<String> = all_remotes
1163+
.into_iter()
1164+
.filter(|r| !used_remotes.contains(r))
1165+
.collect();
1166+
1167+
if unused_remotes.is_empty() {
1168+
log_info("No unused remotes found.");
1169+
} else {
1170+
log_info(&format!("Unused remotes: {}", unused_remotes.join(", ")));
1171+
print!("Remove these remotes? [y/N]: ");
1172+
std::io::stdout().flush()?;
1173+
1174+
let mut input = String::new();
1175+
std::io::stdin().read_line(&mut input)?;
1176+
1177+
if input.trim().to_lowercase() == "y" {
1178+
for remote in unused_remotes {
1179+
log_info(&format!("Removing remote: {}", remote));
1180+
let _ = run_cmd(&["git", "remote", "remove", &remote]);
1181+
}
1182+
log_success("Unused remotes removed.");
1183+
} else {
1184+
log_info("Pruning cancelled.");
1185+
}
1186+
}
1187+
1188+
// Always prune stale worktrees
1189+
log_info("Pruning stale worktrees...");
1190+
let _ = run_cmd(&["git", "worktree", "prune", "--verbose"]);
1191+
log_success("Worktree pruning complete.");
1192+
}
1193+
}
10791194
Commands::Diff { path } => {
10801195
let metadata = load_metadata()?;
10811196
let mut found = false;

0 commit comments

Comments
 (0)