Skip to main content

somatize_tree/
revision.rs

1//! A commit, put somewhere it can be imported from.
2//!
3//! `git` as a subprocess and not `gix`, for now. What is needed here is
4//! `rev-parse` and a worktree, both of which the binary already does correctly,
5//! and a library earns its place when there is work it does better — reading
6//! many blobs, or naming a commit's contents without materialising them. There
7//! is none of that yet.
8
9use std::collections::HashMap;
10use std::fmt;
11use std::path::{Path, PathBuf};
12use std::process::Command;
13
14/// A checkout of one commit, removed when it goes out of scope.
15pub struct Worktree {
16    repo: PathBuf,
17    at: PathBuf,
18    /// The full hash. `HEAD~2` is not something to print back at somebody.
19    commit: String,
20}
21
22impl Worktree {
23    /// Resolves a revspec and lays that commit out under `beneath`, in a
24    /// directory called `as_`.
25    ///
26    /// The name is the caller's and not the commit's, because the two sides of
27    /// a comparison are allowed to be the **same** commit — asking what a store
28    /// already holds is a diff of one revision against itself — and two
29    /// worktrees of one commit under one name is a refusal from git. Detached
30    /// for the same reason: a branch checked out twice is another one.
31    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    /// The short hash, which is what a person reads.
68    pub fn named(&self) -> &str {
69        &self.commit[..12.min(self.commit.len())]
70    }
71
72    /// The whole hash, which is what a cache is keyed on: twelve characters is
73    /// plenty to read and too few to name a stored answer after.
74    pub fn commit(&self) -> &str {
75        &self.commit
76    }
77}
78
79impl Drop for Worktree {
80    fn drop(&mut self) {
81        // A worktree left behind is not just a directory: git keeps a record
82        // of it and the next `worktree add` on the same commit refuses. Said
83        // out loud, because the fix is `git worktree prune` and nobody guesses
84        // that.
85        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
105/// What to ask for when what is wanted is the whole investigation.
106pub const ALL: &str = "--all";
107
108/// The commits to walk, newest first — git's own order.
109///
110/// Three ways of asking: a **range**, `main~10..main`, which is exactly what
111/// somebody meant; a **revspec**, `HEAD`, meaning the history back from there
112/// capped at `most`; and [`ALL`], every branch, which is the default because
113/// that is the shape an investigation has.
114///
115/// A range cannot be the default, because `HEAD~10..HEAD` in a repository with
116/// four commits is not an empty answer but an unknown-revision error, and that
117/// is not something to hand somebody who typed nothing at all.
118pub 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        // Every branch, because a walk from one tip cannot see its own
122        // siblings: `rev-list HEAD` follows ancestry and three variants of one
123        // idea are three branches off one commit, not ancestors of each other.
124        (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
135/// The commit under each of these that is not one of them.
136///
137/// A range says which commits to **show**, and a step needs the one below it:
138/// with three branches that is one under **each**, since every line needs
139/// something of its own to be compared against.
140pub 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
152/// The commit before this one, if it has one.
153///
154/// Reaches past the range for the same reason `git log -p` does: otherwise the
155/// oldest commit shown could not say what it did.
156pub fn parent_of(repo: &Path, commit: &str) -> Option<String> {
157    git(repo, &["rev-parse", "--verify", &format!("{commit}^")]).ok()
158}
159
160/// The full hash a revspec names.
161///
162/// Resolved before anything is written down, never stored as somebody typed
163/// it: `HEAD~2` is another commit tomorrow, and a note has to stay about the
164/// commit it was about.
165pub 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
176/// One file, as that commit had it.
177///
178/// `git show` and not a read off the disk: what is wanted is the file **at
179/// that commit**, and it saves the worktree, which is the expensive part of
180/// everything else here.
181///
182/// Nothing is trimmed. A `trim` on the way through would eat the trailing
183/// newline git wants and the blank lines above it, and a file that comes back
184/// different from how it went out is an edit that deletes code silently.
185pub 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
203/// The path, checked to be inside the repository.
204///
205/// One with `..` in it leaves, and serving that would be a browser reading and
206/// writing wherever it liked on somebody's disk. Reading and forking need the
207/// same check, so it is written once.
208fn 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
216/// Who each of these commits comes from, in one call.
217///
218/// A walk prints a line and a **DAG has edges**: a range flattens three
219/// branches into an order that says nothing about which came from which.
220pub 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/// Which lines of a file one class occupies.
236#[derive(Debug, Clone, Copy)]
237pub struct Splice {
238    /// 1-based, as `inspect.getsourcelines` and every editor count.
239    pub line: u32,
240    pub lines: u32,
241}
242
243impl Splice {
244    /// The whole file with those lines swapped for `what`.
245    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            // The file at that commit is not the file the panel read. Refusing
251            // is the only safe answer: splicing at a guessed offset would cut
252            // the class in half and commit it.
253            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        // A file git is happy with ends in a newline, and so did this one.
265        if whole.ends_with('\n') {
266            out.push('\n');
267        }
268        Ok(out)
269    }
270}
271
272/// A checkout of `from` with one class replaced, and nothing committed.
273///
274/// Shared by checking and forking on purpose: what gets measured has to be the
275/// same tree that gets committed, or a green light means nothing. `branch`
276/// cuts one; `None` leaves it detached, which is what a check wants — asking
277/// whether an edit survives should not litter a repository with the noes.
278pub 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    // The panel shows one class and a file usually holds four. Writing what
317    // the panel showed would silently drop the imports, the sibling classes
318    // and the `build()` that ties them together.
319    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
326/// Takes a worktree back out, so the next `worktree add` on that commit is not
327/// refused by a record git kept of one nobody removed.
328pub 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
340/// Cuts a branch at `from`, replaces one class in `file`, and commits it.
341///
342/// **Editing is forking.** A commit is a version that has already been
343/// measured, so wanting to change one is wanting another variant from here:
344/// this never touches an existing branch and never rewrites anything, and the
345/// worst it can do is leave a branch nobody asked for. In a worktree of its
346/// own, so somebody's checkout, index and unstaged work are left alone.
347pub 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    // The worktree goes either way. The branch stays: on success it is the
366    // point, and on failure it is the only trace of what was attempted.
367    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
379/// Who is saying it. Git's idea of who you are, or the account's.
380pub 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
388/// What each commit was called and when it was made, in one call.
389///
390/// The time is not decoration: which of three variants was tried first is a
391/// question about **when**, and the order a walk arrives in cannot answer it —
392/// for commits made in the same second `rev-list` falls back to the order it
393/// traverses refs, which is their names, so branches would come out
394/// alphabetically and look chronological.
395pub 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
412/// Runs git in a repo and returns its trimmed output.
413fn 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
455/// What is **tracked** and changed, as `git` says it.
456///
457/// Asked before going anywhere: an edit to a tracked file belongs to the
458/// version it was written against, and carrying it to another one is the kind
459/// of help that loses an afternoon.
460///
461/// Untracked files are not it, deliberately. A scratch notebook beside the
462/// code is not work that belongs to where somebody was, and refusing over one
463/// would make the verb unusable on the machine of anybody who keeps one.
464pub 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
473/// Cuts a branch at that commit and moves onto it.
474///
475/// **A branch of its own and never an existing one.** A commit is a version
476/// that has already been measured, so arriving at one is arriving to make the
477/// next variant — and a `checkout` that landed on somebody's branch would put
478/// the next commit on the end of a line that was not being extended.
479pub fn went_to(repo: &Path, branch: &str, commit: &str) -> Result<(), Trouble> {
480    // Asked rather than read off the failure. git speaks the caller's language
481    // — the first draft matched `already exists` and said nothing at all on a
482    // Spanish machine, where it is `ya existe`. A ref either resolves or it
483    // does not, in every locale there is.
484    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}