1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
use std::path::{Path, PathBuf};
use std::process::Command;
use color_eyre::eyre::{Result, WrapErr, bail};
use tracing::info;
/// Derive a bare repo path from a remote URL.
/// e.g. "https://github.com/user/repo" -> repos_dir/github.com/user/repo.git
pub fn repo_path(repos_dir: &Path, src: &str) -> PathBuf {
let url = src
.strip_prefix("https://")
.or_else(|| src.strip_prefix("http://"))
.unwrap_or(src)
.trim_end_matches(".git");
repos_dir.join(format!("{url}.git"))
}
/// Path to a worktree for a specific revision.
/// e.g. repos_dir/github.com/user/repo.git/worktrees/<rev_short>
pub fn worktree_path(repo_path: &Path, rev: &str) -> PathBuf {
repo_path.join("pinch-worktrees").join(&rev[..12])
}
pub fn clone_or_fetch(repo_path: &Path, src: &str) -> Result<()> {
if repo_path.join("HEAD").is_file() {
let status = Command::new("git")
.args(["fetch", "--quiet", "origin"])
.current_dir(repo_path)
.status()
.wrap_err("failed to run git fetch")?;
if !status.success() {
bail!("git fetch failed for {}", src);
}
} else {
fs_err::create_dir_all(repo_path)?;
let status = Command::new("git")
.args(["clone", "--quiet", "--bare", src, "."])
.current_dir(repo_path)
.status()
.wrap_err("failed to run git clone")?;
if !status.success() {
bail!("git clone failed for {}", src);
}
}
Ok(())
}
pub fn resolve_ref(repo_path: &Path, git_ref: &str) -> Result<String> {
// Try origin/<ref> first (branch)
let output = Command::new("git")
.args(["rev-parse", &format!("origin/{git_ref}")])
.current_dir(repo_path)
.output()
.wrap_err("failed to run git rev-parse")?;
if output.status.success() {
return Ok(String::from_utf8_lossy(&output.stdout).trim().to_string());
}
// Fall back to treating it as a tag or raw ref
let output = Command::new("git")
.args(["rev-parse", git_ref])
.current_dir(repo_path)
.output()
.wrap_err("failed to run git rev-parse")?;
if !output.status.success() {
bail!(
"could not resolve ref '{}' in {}",
git_ref,
repo_path.display()
);
}
Ok(String::from_utf8_lossy(&output.stdout).trim().to_string())
}
/// Ensure a worktree exists at the given rev and return its path.
pub fn ensure_worktree(repo_path: &Path, rev: &str) -> Result<PathBuf> {
let wt_path = worktree_path(repo_path, rev);
if wt_path.join(".git").exists() {
// Worktree already exists, make sure it's at the right rev
let status = Command::new("git")
.args(["checkout", "--quiet", "--detach", rev])
.current_dir(&wt_path)
.status()
.wrap_err("failed to run git checkout in worktree")?;
if !status.success() {
bail!("git checkout failed for {} in {}", rev, wt_path.display());
}
} else {
fs_err::create_dir_all(wt_path.parent().unwrap())?;
let status = Command::new("git")
.args([
"worktree",
"add",
"--quiet",
"--detach",
&wt_path.to_string_lossy(),
rev,
])
.current_dir(repo_path)
.status()
.wrap_err("failed to run git worktree add")?;
if !status.success() {
bail!(
"git worktree add failed for {} in {}",
rev,
repo_path.display()
);
}
}
Ok(wt_path)
}
/// Remove worktrees and repos that aren't referenced by the lockfile.
pub fn prune(repos_dir: &Path, lockfile: &crate::lockfile::Lockfile) -> Result<()> {
use std::collections::{HashMap, HashSet};
// Build a map: repo_path -> set of used rev prefixes
let mut used: HashMap<PathBuf, HashSet<String>> = HashMap::new();
for plugin in lockfile.plugins.values() {
let rp = repo_path(repos_dir, &plugin.src);
used.entry(rp)
.or_default()
.insert(plugin.rev[..12].to_string());
}
// Walk all bare repos: prune stale worktrees, remove unused repos
visit_repos(repos_dir, &used)?;
Ok(())
}
fn visit_repos(
dir: &Path,
used: &std::collections::HashMap<PathBuf, std::collections::HashSet<String>>,
) -> Result<()> {
let entries = match fs_err::read_dir(dir) {
Ok(e) => e,
Err(_) => return Ok(()),
};
for entry in entries {
let entry = entry?;
let path = entry.path();
if !path.is_dir() {
continue;
}
if path.file_name().is_some_and(|n| n == "pinch-worktrees") {
// This is a worktrees dir inside a bare repo
let repo_path = path.parent().unwrap();
let used_revs = used.get(repo_path);
for wt_entry in fs_err::read_dir(&path)? {
let wt_entry = wt_entry?;
let wt_name = wt_entry.file_name();
let wt_name_str = wt_name.to_string_lossy();
let is_used = used_revs.is_some_and(|revs| revs.contains(wt_name_str.as_ref()));
if !is_used {
let wt_path = wt_entry.path();
// Remove the git worktree properly
let _ = std::process::Command::new("git")
.args(["worktree", "remove", "--force", &wt_path.to_string_lossy()])
.current_dir(repo_path)
.status();
// If that fails, just remove the directory
if wt_path.exists() {
fs_err::remove_dir_all(&wt_path)?;
}
info!("pruned worktree: {}", wt_path.display());
}
}
} else if path.join("HEAD").is_file() {
// This is a bare repo — remove it if no plugins reference it
if !used.contains_key(&path) {
fs_err::remove_dir_all(&path)?;
info!("removed unused repo: {}", path.display());
}
} else {
visit_repos(&path, used)?;
}
}
// Remove empty parent directories left behind
remove_empty_dirs(dir);
Ok(())
}
/// Remove `dir` and its ancestors (up to but not including the repos root)
/// if they're empty. Stops at the first non-empty directory.
fn remove_empty_dirs(dir: &Path) {
if let Ok(mut entries) = fs_err::read_dir(dir)
&& entries.next().is_none()
{
let _ = fs_err::remove_dir(dir);
}
}