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}