Skip to main content

onevcs/
import.rs

1//! Making a branch reachable from an identity's registered checkouts.
2//!
3//! The plumbing half of the boundary. A branch a run left in its own clone is
4//! reachable from nothing a later run clones, and preserved work whose name is
5//! already spent cannot be pinned again — so both were done by hand, with
6//! `git fetch <clone> <branch>:<branch>` and `git branch preserved/<name>`, which
7//! is exactly the improvisation the rest of this crate exists to make unnecessary.
8//! Ref plumbing over an identity's own checkouts belongs to the tool that owns the
9//! identity.
10//!
11//! **Ref writes only.** Nothing here checks anything out, and no working tree in
12//! any registered checkout is touched: the branch is fetched into a scratch ref,
13//! judged there, and then the destination's ref is pointed at it. That is also why
14//! a name the destination has checked out is refused rather than written — moving
15//! that ref would leave a tree git believes is something else.
16//!
17//! Where it looks when nobody says is [`branch::locate`], which is where `recover`
18//! and `publish-branch` look: one search over the places this identity keeps work,
19//! so a branch one of them can land is a branch this can reach.
20
21use std::path::{Path, PathBuf};
22
23use crate::error::{Error, Result};
24use crate::registry::Registry;
25use crate::store::{self, Resolution};
26use crate::workspace::Ref;
27use crate::{branch, git, guidance, ids, policy};
28
29/// The alternate name a refusal offers, which is the move both refusals are
30/// answered by: a second name for work whose first one is spent or taken.
31///
32/// Flattened rather than nested, so the suggestion cannot collide with a ref
33/// somebody already has — git refuses `preserved/feature` beside
34/// `preserved/feature/thing`, and a refusal that names an unusable command names
35/// no command at all.
36fn preserved(name: &str) -> String {
37    format!("preserved/{}", policy::branch_slug(name))
38}
39
40/// The invocation both refusals below are answered by: this same import under a
41/// second name.
42///
43/// It carries the `--from` it was given rather than rebuilding the command from the
44/// destination alone. Dropping it would send the operator back to the search over
45/// every copy of the branch this identity holds — and both refusals here are reached
46/// with a second copy of that name already in the destination, which is the pair that
47/// search declines to choose between. So a command without it answers the refusal
48/// that printed it with a different refusal, which is the dead end an offered verb
49/// exists not to be.
50fn under_another_name(repo: &Path, branch: &str, from: Option<&str>, name: &str) -> String {
51    let mut argv = vec!["onevcs", "import", branch, "--repo"];
52    let repo = repo.to_string_lossy();
53    argv.push(&repo);
54    if let Some(from) = from {
55        argv.extend_from_slice(&["--from", from]);
56    }
57    let second = preserved(name);
58    argv.extend_from_slice(&["--as", &second]);
59    guidance::command(argv)
60}
61
62/// Where an import read the branch from.
63#[derive(Debug, Clone, PartialEq, Eq)]
64pub enum Source {
65    /// Another repository on this host: a run clone, or another checkout.
66    Repository(PathBuf),
67    /// A remote of the destination checkout, and the branch as it is named there.
68    Remote {
69        /// The remote git was asked, which is one the destination has configured —
70        /// `source_of` takes it off `git remote` rather than off the argument.
71        // llmlint: ignore[invalid_states_unrepresentable] a remote *name* has no type in
72        // this crate and would be the only one: every other place that names one — the
73        // `"origin"` every fetch, push, and `default_branch` call passes — spells it as a
74        // `&str`, so a newtype here would be a shape one construction site upheld and
75        // nothing else did. What decides this value is that it is in `git remote`'s own
76        // listing, which no conversion could check without running git.
77        remote: String,
78        /// The branch on it, which `--from origin/other` may spell differently
79        /// from the name the import writes.
80        branch: Ref,
81    },
82}
83
84impl Source {
85    /// How the report and every refusal name it.
86    pub fn describe(&self) -> String {
87        match self {
88            Source::Repository(path) => path.display().to_string(),
89            Source::Remote { remote, branch } => format!("{remote}/{branch}"),
90        }
91    }
92
93    /// What git is handed as the thing to fetch from.
94    fn location(&self) -> String {
95        match self {
96            Source::Repository(path) => path.to_string_lossy().into_owned(),
97            Source::Remote { remote, .. } => remote.clone(),
98        }
99    }
100
101    /// The branch as the source names it.
102    fn branch<'a>(&'a self, asked: &'a Ref) -> &'a Ref {
103        match self {
104            Source::Repository(_) => asked,
105            Source::Remote { branch, .. } => branch,
106        }
107    }
108}
109
110/// What one import did.
111#[derive(Debug, Clone)]
112pub struct Imported {
113    /// The checkout the ref was written in.
114    pub destination: PathBuf,
115    /// The local name it was written under.
116    pub name: Ref,
117    /// Where the branch was read from.
118    pub source: Source,
119    /// The commit the name now points at.
120    // llmlint: ignore[invalid_states_unrepresentable] `git::tip` answered it, off a ref
121    // this command had just fetched; the crate's `Sha` is the contract's public wrapper
122    // and validates nothing, so spelling it here would make no state unrepresentable.
123    pub tip: String,
124    /// What the write actually did to the name.
125    pub wrote: Wrote,
126}
127
128/// What one import did to the name it wrote.
129///
130/// Three answers, because a caller reads this to find out what changed and the two
131/// that are not "the name is new" differ: a branch already at the commit the source
132/// has moved nothing, and reporting that as a fast-forward names an advance that
133/// did not happen.
134#[derive(Debug, Clone, Copy, PartialEq, Eq)]
135pub enum Wrote {
136    /// The destination had no branch of that name.
137    Created,
138    /// It had one, and the import moved it forward.
139    FastForwarded,
140    /// It had one, already at the commit the source has.
141    Unchanged,
142}
143
144/// Make one branch reachable from an identity's registered checkouts.
145pub fn run(
146    registry: &Registry,
147    repo: &Path,
148    branch: &str,
149    from: Option<&str>,
150    under: Option<&str>,
151) -> Result<Imported> {
152    // A path this build cannot read as text names no registered checkout, and it is
153    // refused where every `--repo` is rather than a second time here.
154    let resolution = store::resolve_path(registry, repo)?;
155    let destination = resolution.publication.clone();
156    // Both names go through the one conversion git's own parser decides, so an
157    // unusable one is refused here rather than by whichever git command met it first.
158    let asked = Ref::try_from(branch.to_owned()).map_err(|reason| Error::Invalid {
159        reason: format!(
160            "{reason}; `onevcs recoverable` lists every preserved branch by name and the \
161             checkout it is in"
162        ),
163    })?;
164    let name = match under {
165        Some(under) => Ref::try_from(under.to_owned()).map_err(|reason| Error::Invalid {
166            reason: format!("--as {reason}: it is not a valid branch name"),
167        })?,
168        None => asked.clone(),
169    };
170    let source = source_of(registry, &resolution, repo, &destination, &asked, from)?;
171    // Refused before anything is fetched: the whole promise of this verb is that no
172    // working tree moves, and pointing a checked-out branch somewhere else leaves
173    // git holding an index and a tree that describe a commit the ref no longer names.
174    //
175    // After the source is resolved rather than before, though nothing between the two
176    // fetches: the command this refusal prints carries the `--from` it was given, and a
177    // `--from` nobody has decided the meaning of is a value to refuse rather than to
178    // hand back to an operator to paste.
179    let current = git::current_branch(&destination)?;
180    if current == *name {
181        return Err(Error::Invalid {
182            reason: format!(
183                "{destination} has {name:?} checked out, and import only ever writes refs — \
184                 moving that one would leave its working tree describing a commit the branch no \
185                 longer names. Import it under another name with `{command}`",
186                destination = destination.display(),
187                command = under_another_name(repo, branch, from, &name),
188            ),
189        });
190    }
191
192    let scratch = format!("refs/onevcs/import/{}", ids::unique());
193    let fetched = git::fetch_into_ref(
194        &destination,
195        &source.location(),
196        source.branch(&asked),
197        &scratch,
198    )?;
199    let imported = (|| -> Result<Imported> {
200        if !fetched {
201            return Err(Error::Invalid {
202                reason: format!(
203                    "{source} has no branch {branch:?} to import; `onevcs recoverable` lists every \
204                     preserved branch and the checkout it is in",
205                    source = source.describe(),
206                    branch = source.branch(&asked),
207                ),
208            });
209        }
210        let tip = git::tip(&destination, &scratch).ok_or_else(|| Error::Invalid {
211            reason: format!(
212                "the fetch of {branch:?} from {} left no commit this build can read",
213                source.describe(),
214                branch = source.branch(&asked),
215            ),
216        })?;
217        let held = git::tip(&destination, &format!("refs/heads/{name}"));
218        let wrote = match &held {
219            Some(held) if *held == tip => Wrote::Unchanged,
220            Some(_) => {
221                refuse_a_rewrite(&destination, &name, &scratch, repo, &asked, from)?;
222                Wrote::FastForwarded
223            }
224            None => Wrote::Created,
225        };
226        git::update_ref(&destination, &format!("refs/heads/{name}"), &tip)?;
227        Ok(Imported {
228            destination: destination.clone(),
229            name: name.clone(),
230            source: source.clone(),
231            tip,
232            wrote,
233        })
234    })();
235    git::delete_ref(&destination, &scratch);
236    imported
237}
238
239/// Refuse to move an existing name onto something it does not descend from,
240/// naming the commits that would go.
241///
242/// A branch in a registered checkout is the durable record of whatever wrote it,
243/// and this verb is reached by somebody who wants a *second* copy reachable — so an
244/// overwrite here is work lost with nothing left to recover it from. Which commits
245/// they are is the whole of what makes the refusal actionable: `--as` is the way
246/// through, and it is only the right way through once an operator can see what the
247/// name they asked for already holds.
248fn refuse_a_rewrite(
249    destination: &Path,
250    name: &Ref,
251    scratch: &str,
252    repo: &Path,
253    branch: &Ref,
254    from: Option<&str>,
255) -> Result<()> {
256    if git::is_ancestor(destination, name, scratch)? {
257        return Ok(());
258    }
259    let losing = git::log_messages(destination, scratch, name)?;
260    let named: Vec<String> = losing
261        .iter()
262        .map(|commit| {
263            let subject = commit.message.lines().next().unwrap_or("").trim();
264            format!("{} {subject}", &commit.sha[..commit.sha.len().min(12)])
265        })
266        .collect();
267    Err(Error::Invalid {
268        reason: format!(
269            "{destination} already has {name:?}, and importing over it is not a fast-forward: \
270             {count} commit(s) it holds are on no branch this import brings. They would be lost: \
271             {lost}. Import under another name with `{command}`, or delete {name:?} there first if \
272             what it holds is spent",
273            destination = destination.display(),
274            count = named.len(),
275            lost = named.join("; "),
276            command = under_another_name(repo, branch, from, name),
277        ),
278    })
279}
280
281/// Where the branch is read from: what `--from` named, or wherever this identity
282/// left it.
283///
284/// A `--from` is decided by what it *is* rather than by trying each spelling until
285/// one works: a directory is a repository, and anything else is a remote of the
286/// destination — spelled as the remote alone, or as `<remote>/<branch>` for a
287/// branch the remote names differently. A value that is neither is refused naming
288/// both, because a fetch that silently fell through to the wrong one would import
289/// somebody else's commit under this name.
290fn source_of(
291    registry: &Registry,
292    resolution: &Resolution,
293    repo: &Path,
294    destination: &Path,
295    branch: &Ref,
296    from: Option<&str>,
297) -> Result<Source> {
298    let Some(from) = from else {
299        let base = git::default_branch(destination, "origin")?;
300        // Copies of the branch that have diverged are the search's own refusal, and
301        // what it tells an operator to do once they have reconciled them is this
302        // verb's: an import that could not decide where to read from is answered by
303        // running the same import again, not by landing anything.
304        let named: &str = branch;
305        let again =
306            guidance::command(["onevcs", "import", named, "--repo", &repo.to_string_lossy()]);
307        return Ok(Source::Repository(branch::locate(
308            registry,
309            resolution,
310            branch,
311            &base,
312            &format!("run `{again}` again"),
313        )?));
314    };
315    if git::is_repo(Path::new(from)) {
316        return Ok(Source::Repository(PathBuf::from(from)));
317    }
318    let remotes = git::remotes(destination)?;
319    if remotes.iter().any(|remote| remote == from) {
320        return Ok(Source::Remote {
321            remote: from.to_owned(),
322            branch: branch.clone(),
323        });
324    }
325    if let Some((remote, named)) = from.split_once('/') {
326        if remotes.iter().any(|candidate| candidate == remote) {
327            // The half after the remote goes on to spell a refspec, so it is decided
328            // by the conversion that decides branch names rather than merely being
329            // non-empty — `origin/` and `origin/..` both name a remote this
330            // repository has and no branch anything could fetch.
331            let named = Ref::try_from(named.to_owned()).map_err(|reason| Error::Invalid {
332                reason: format!(
333                    "--from {from:?} names remote {remote:?} and {reason}; spell it \
334                     `{remote}/<branch>`"
335                ),
336            })?;
337            return Ok(Source::Remote {
338                remote: remote.to_owned(),
339                branch: named,
340            });
341        }
342    }
343    Err(Error::Invalid {
344        reason: format!(
345            "--from {from:?} is neither a git repository on this host nor a remote of {}: its \
346             remotes are {remotes}. Pass the path of a checkout or a run clone — `onevcs \
347             recoverable` reports where a preserved branch is — or a remote ref such as \
348             `origin/{branch}`, or omit --from to search everywhere this identity keeps work",
349            destination.display(),
350            remotes = if remotes.is_empty() {
351                "none".to_owned()
352            } else {
353                remotes.join(", ")
354            },
355        ),
356    })
357}