1use std::collections::HashMap;
10use std::fmt;
11use std::path::{Path, PathBuf};
12use std::process::Command;
13
14pub struct Worktree {
16 repo: PathBuf,
17 at: PathBuf,
18 commit: String,
20}
21
22impl Worktree {
23 pub fn of(repo: &Path, revspec: &str, beneath: &Path, as_: &str) -> Result<Self, Trouble> {
32 let commit = git(
33 repo,
34 &["rev-parse", "--verify", &format!("{revspec}^{{commit}}")],
35 )
36 .map_err(|said| Trouble::NoSuchRevision {
37 revspec: revspec.to_string(),
38 said,
39 })?;
40 let at = beneath.join(as_);
41 git(
42 repo,
43 &[
44 "worktree",
45 "add",
46 "--detach",
47 "--quiet",
48 &at.display().to_string(),
49 &commit,
50 ],
51 )
52 .map_err(|said| Trouble::NoWorktree {
53 commit: commit.clone(),
54 said,
55 })?;
56 Ok(Self {
57 repo: repo.to_path_buf(),
58 at,
59 commit,
60 })
61 }
62
63 pub fn path(&self) -> &Path {
64 &self.at
65 }
66
67 pub fn named(&self) -> &str {
69 &self.commit[..12.min(self.commit.len())]
70 }
71
72 pub fn commit(&self) -> &str {
75 &self.commit
76 }
77}
78
79impl Drop for Worktree {
80 fn drop(&mut self) {
81 let removing = git(
86 &self.repo,
87 &[
88 "worktree",
89 "remove",
90 "--force",
91 &self.at.display().to_string(),
92 ],
93 );
94 if let Err(said) = removing {
95 eprintln!(
96 "the worktree at {} could not be removed: {said}\n\
97 `git worktree prune` in {} clears the record it left.",
98 self.at.display(),
99 self.repo.display(),
100 );
101 }
102 }
103}
104
105pub const ALL: &str = "--all";
107
108pub fn commits_in(repo: &Path, asked: &str, most: usize) -> Result<Vec<String>, Trouble> {
119 let capped = most.to_string();
120 let how: Vec<&str> = match (asked, asked.contains("..")) {
121 (ALL, _) => vec!["rev-list", "--all", "-n", &capped],
125 (_, true) => vec!["rev-list", asked],
126 (_, false) => vec!["rev-list", "-n", &capped, asked],
127 };
128 let said = git(repo, &how).map_err(|said| Trouble::NoSuchRevision {
129 revspec: asked.to_string(),
130 said,
131 })?;
132 Ok(said.lines().map(str::to_string).collect())
133}
134
135pub fn beneath(repo: &Path, commits: &[String]) -> Vec<String> {
141 let inside: std::collections::HashSet<&str> = commits.iter().map(String::as_str).collect();
142 let mut under: Vec<String> = parents_of(repo, commits)
143 .into_iter()
144 .flat_map(|(_, parents)| parents)
145 .filter(|parent| !inside.contains(parent.as_str()))
146 .collect();
147 under.sort();
148 under.dedup();
149 under
150}
151
152pub fn parent_of(repo: &Path, commit: &str) -> Option<String> {
157 git(repo, &["rev-parse", "--verify", &format!("{commit}^")]).ok()
158}
159
160pub fn named(repo: &Path, revspec: &str) -> Result<String, Trouble> {
166 git(
167 repo,
168 &["rev-parse", "--verify", &format!("{revspec}^{{commit}}")],
169 )
170 .map_err(|said| Trouble::NoSuchRevision {
171 revspec: revspec.to_string(),
172 said,
173 })
174}
175
176pub fn read(repo: &Path, commit: &str, file: &str) -> Result<String, Trouble> {
186 let of = |said: String| Trouble::NoSuchFile {
187 file: file.to_string(),
188 said,
189 };
190 within(file).map_err(of)?;
191 let said = Command::new("git")
192 .arg("-C")
193 .arg(repo)
194 .args(["show", &format!("{commit}:{file}")])
195 .output()
196 .map_err(|why| of(format!("git could not be run: {why}")))?;
197 match said.status.success() {
198 true => Ok(String::from_utf8_lossy(&said.stdout).into_owned()),
199 false => Err(of(String::from_utf8_lossy(&said.stderr).trim().to_string())),
200 }
201}
202
203fn within(file: &str) -> Result<PathBuf, String> {
209 let inside = PathBuf::from(file);
210 match inside.is_absolute() || inside.components().any(|of| of.as_os_str() == "..") {
211 true => Err(format!("`{file}` is not a path inside the repository")),
212 false => Ok(inside),
213 }
214}
215
216pub fn parents_of(repo: &Path, commits: &[String]) -> Vec<(String, Vec<String>)> {
221 let mut asking = vec!["rev-list", "--no-walk", "--parents"];
222 asking.extend(commits.iter().map(String::as_str));
223 git(repo, &asking)
224 .map(|said| {
225 said.lines()
226 .filter_map(|line| {
227 let mut of = line.split_whitespace().map(str::to_string);
228 Some((of.next()?, of.collect()))
229 })
230 .collect()
231 })
232 .unwrap_or_default()
233}
234
235#[derive(Debug, Clone, Copy)]
237pub struct Splice {
238 pub line: u32,
240 pub lines: u32,
241}
242
243impl Splice {
244 fn into(self, whole: &str, what: &str) -> Result<String, String> {
246 let lines: Vec<&str> = whole.lines().collect();
247 let from = self.line.saturating_sub(1) as usize;
248 let to = from + self.lines as usize;
249 if from > lines.len() || to > lines.len() {
250 return Err(format!(
254 "lines {}..{} are not in a file of {}: the source moved since it was read",
255 self.line,
256 to,
257 lines.len()
258 ));
259 }
260 let mut said = lines[..from].to_vec();
261 said.extend(what.trim_end_matches('\n').lines());
262 said.extend_from_slice(&lines[to..]);
263 let mut out = said.join("\n");
264 if whole.ends_with('\n') {
266 out.push('\n');
267 }
268 Ok(out)
269 }
270}
271
272pub fn laid_out(
279 repo: &Path,
280 from: &str,
281 branch: Option<&str>,
282 file: &str,
283 at: Splice,
284 what: &str,
285) -> Result<(tempfile::TempDir, PathBuf), Trouble> {
286 let named = branch.unwrap_or("");
287 let of = |said: String| Trouble::NoSuchBranch {
288 branch: named.to_string(),
289 said,
290 };
291 if let Some(branch) = branch
292 && (branch.is_empty() || branch.starts_with('-') || branch.contains(".."))
293 {
294 return Err(of(
295 "a branch name cannot be empty, start with `-`, or contain `..`".into(),
296 ));
297 }
298 let inside = within(file).map_err(of)?;
299
300 let held = tempfile::tempdir().map_err(|why| Trouble::NoWorktree {
301 commit: from.to_string(),
302 said: why.to_string(),
303 })?;
304 let working = held.path().join("apart");
305 let mut how = vec!["worktree", "add", "--quiet"];
306 if let Some(branch) = branch {
307 how.extend(["-b", branch]);
308 } else {
309 how.push("--detach");
310 }
311 let at_path = working.display().to_string();
312 how.extend([at_path.as_str(), from]);
313 git(repo, &how).map_err(of)?;
314
315 let writing = working.join(&inside);
316 let spliced = std::fs::read_to_string(&writing)
320 .map_err(|why| of(why.to_string()))
321 .and_then(|whole| at.into(&whole, what).map_err(of))?;
322 std::fs::write(&writing, spliced).map_err(|why| of(why.to_string()))?;
323 Ok((held, working))
324}
325
326pub fn forget(repo: &Path, working: &Path) -> Result<String, String> {
329 git(
330 repo,
331 &[
332 "worktree",
333 "remove",
334 "--force",
335 &working.display().to_string(),
336 ],
337 )
338}
339
340pub fn forked(
348 repo: &Path,
349 from: &str,
350 branch: &str,
351 file: &str,
352 at: Splice,
353 what: &str,
354 said: &str,
355) -> Result<String, Trouble> {
356 let (_held, working) = laid_out(repo, from, Some(branch), file, at, what)?;
357 let of = |trouble: String| Trouble::NoSuchBranch {
358 branch: branch.to_string(),
359 said: trouble,
360 };
361 let done = git(&working, &["add", "--", file])
362 .and_then(|_| git(&working, &["commit", "-q", "-m", said]))
363 .and_then(|_| git(&working, &["rev-parse", "HEAD"]));
364
365 let _ = git(
368 repo,
369 &[
370 "worktree",
371 "remove",
372 "--force",
373 &working.display().to_string(),
374 ],
375 );
376 done.map_err(of)
377}
378
379pub fn whoami(repo: &Path) -> String {
381 git(repo, &["config", "user.name"])
382 .ok()
383 .filter(|said| !said.is_empty())
384 .or_else(|| std::env::var("USER").ok())
385 .unwrap_or_else(|| "nobody".to_string())
386}
387
388pub fn told(repo: &Path, commits: &[String]) -> HashMap<String, (u64, String)> {
396 let mut asking = vec!["log", "--no-walk", "--format=%H%x00%ct%x00%s"];
397 asking.extend(commits.iter().map(String::as_str));
398 git(repo, &asking)
399 .map(|said| {
400 said.lines()
401 .filter_map(|line| {
402 let mut of = line.split('\0');
403 let commit = of.next()?.to_string();
404 let when = of.next()?.parse().ok()?;
405 Some((commit, (when, of.next().unwrap_or_default().to_string())))
406 })
407 .collect()
408 })
409 .unwrap_or_default()
410}
411
412fn git(repo: &Path, args: &[&str]) -> Result<String, String> {
414 let said = Command::new("git")
415 .arg("-C")
416 .arg(repo)
417 .args(args)
418 .output()
419 .map_err(|why| format!("git could not be run: {why}"))?;
420 match said.status.success() {
421 true => Ok(String::from_utf8_lossy(&said.stdout).trim().to_string()),
422 false => Err(String::from_utf8_lossy(&said.stderr).trim().to_string()),
423 }
424}
425
426#[derive(Debug)]
427pub enum Trouble {
428 NoSuchRevision { revspec: String, said: String },
429 NoSuchFile { file: String, said: String },
430 NoWorktree { commit: String, said: String },
431 NoSuchBranch { branch: String, said: String },
432}
433
434impl fmt::Display for Trouble {
435 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
436 match self {
437 Self::NoSuchRevision { revspec, said } => {
438 write!(f, "`{revspec}` is not a commit here: {said}")
439 }
440 Self::NoSuchFile { file, said } => {
441 write!(f, "`{file}` could not be read: {said}")
442 }
443 Self::NoWorktree { commit, said } => {
444 write!(f, "{commit} could not be laid out: {said}")
445 }
446 Self::NoSuchBranch { branch, said } => {
447 write!(f, "`{branch}` could not be cut: {said}")
448 }
449 }
450 }
451}
452
453impl std::error::Error for Trouble {}
454
455pub fn dirty(repo: &Path) -> Result<String, Trouble> {
465 git(repo, &["status", "--porcelain", "--untracked-files=no"]).map_err(|said| {
466 Trouble::NoWorktree {
467 commit: "HEAD".into(),
468 said,
469 }
470 })
471}
472
473pub fn went_to(repo: &Path, branch: &str, commit: &str) -> Result<(), Trouble> {
480 if git(
485 repo,
486 &["rev-parse", "--verify", &format!("refs/heads/{branch}")],
487 )
488 .is_ok()
489 {
490 return Err(Trouble::NoSuchBranch {
491 branch: branch.to_string(),
492 said: format!(
493 "`{branch}` is already a branch. Arriving at a version that has been measured \
494 is arriving to make the next variant, so this never joins a line somebody is \
495 already on — name another with `--branch`"
496 ),
497 });
498 }
499 git(repo, &["checkout", "-q", "-b", branch, commit])
500 .map(|_| ())
501 .map_err(|said| Trouble::NoSuchBranch {
502 branch: branch.to_string(),
503 said,
504 })
505}