Skip to content

Commit 4a4399b

Browse files
committed
refactor: Move (remove)symlink_recursive into Deployer
1 parent 1458f0f commit 4a4399b

File tree

4 files changed

+217
-218
lines changed

4 files changed

+217
-218
lines changed

src/deploy.rs

Lines changed: 154 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -3,11 +3,17 @@ use std::{
33
process::Stdio,
44
};
55

6+
use fs_err::PathExt as _;
67
use miette::{Context as _, IntoDiagnostic};
8+
use normalize_path::NormalizePath as _;
79
use which::which_global;
810

911
use crate::util::PathExt as _;
1012

13+
/// Struct that keeps track of the deployment and undeployment process of multiple symlinks.
14+
///
15+
/// We keep track of all created symlinks, as well as all symlinks where the creation or deletion failed due to insufficient permissions.
16+
/// In case of missing permissions, you can then use [`Deployer::try_run_elevated()`] to retry the operation with elevated privileges.
1117
#[derive(Default, Debug)]
1218
pub struct Deployer {
1319
/// Symlinks that were successfully created
@@ -53,9 +59,8 @@ impl Deployer {
5359
if err.kind() != std::io::ErrorKind::PermissionDenied {
5460
return Err(err).into_diagnostic().wrap_err_with(|| {
5561
format!(
56-
"Failed to create symlink at {} -> {}",
57-
link.abbr(),
58-
original.abbr()
62+
"Failed to create symlink at {}",
63+
format_symlink(link.abbr(), original.abbr())
5964
)
6065
})?;
6166
}
@@ -69,7 +74,7 @@ impl Deployer {
6974
/// Remove a symlink from at the path `link` pointing to the `original` file.
7075
pub fn delete_symlink(&mut self, path: impl AsRef<Path>) -> miette::Result<()> {
7176
let path = path.as_ref();
72-
tracing::trace!("Planning to delete symlink at {}", path.abbr());
77+
tracing::trace!("Deleting symlink at {}", path.abbr());
7378
if !path.is_symlink() {
7479
miette::bail!("Path is not a symlink: {}", path.abbr());
7580
}
@@ -101,6 +106,141 @@ impl Deployer {
101106
Ok(())
102107
}
103108

109+
/// Set up a symlink from the given `link_path` to the given `actual_path`, recursively.
110+
/// Also takes the `egg_root` dir, to ensure we can safely delete any stale symlinks on the way there.
111+
///
112+
/// Requires all paths to be absolute, will panic otherwise.
113+
///
114+
/// This means:
115+
/// - If `link_path` exists and is a file, abort
116+
/// - If `link_path` exists and is a symlink into the egg dir, remove the symlink and then continue.
117+
/// - If `actual_path` is a file, symlink.
118+
/// - If `actual_path` is a directory that does not exist in `link_path`, symlink it.
119+
/// - If `actual_path` is a directory that already exists in `link_path`, recurse into it and `symlink_recursive` `actual_path`s children.
120+
#[tracing::instrument(skip_all, fields(
121+
egg_root = egg_root.as_ref().abbr(),
122+
actual_path = actual_path.as_ref().abbr(),
123+
link_path = link_path.as_ref().abbr()
124+
))]
125+
pub fn symlink_recursive(
126+
&mut self,
127+
egg_root: impl AsRef<Path>,
128+
actual_path: impl AsRef<Path>,
129+
link_path: &impl AsRef<Path>,
130+
) -> miette::Result<()> {
131+
fn inner(
132+
deployer: &mut Deployer,
133+
egg_root: PathBuf,
134+
actual_path: PathBuf,
135+
link_path: PathBuf,
136+
) -> miette::Result<()> {
137+
let actual_path = actual_path.normalize();
138+
let link_path = link_path.normalize();
139+
let egg_root = egg_root.normalize();
140+
link_path.assert_absolute("link_path");
141+
actual_path.assert_absolute("actual_path");
142+
actual_path.assert_starts_with(&egg_root, "actual_path");
143+
tracing::trace!(
144+
"symlink_recursive({}, {})",
145+
actual_path.abbr(),
146+
link_path.abbr()
147+
);
148+
149+
let actual_path = actual_path.canonical()?;
150+
151+
if link_path.is_symlink() {
152+
let link_target = link_path.fs_err_read_link().into_diagnostic()?;
153+
if link_target == actual_path {
154+
deployer.add_created_symlink(link_path);
155+
return Ok(());
156+
} else if link_target.exists() {
157+
miette::bail!(
158+
"Failed to create symlink {}, as a file already exists there",
159+
format_symlink(link_path.abbr(), actual_path.abbr())
160+
);
161+
} else if link_target.starts_with(&egg_root) {
162+
tracing::info!(
163+
"Removing dead symlink {}",
164+
format_symlink(link_path.abbr(), link_target.abbr())
165+
);
166+
deployer.delete_symlink(&link_path)?;
167+
cov_mark::hit!(remove_dead_symlink);
168+
// After we've removed that file, creating the symlink later will succeed!
169+
} else {
170+
miette::bail!(
171+
"Encountered dead symlink, but it doesn't target the egg dir: {}",
172+
link_path.abbr(),
173+
);
174+
}
175+
} else if link_path.exists() {
176+
tracing::trace!("link_path exists as non-symlink {}", link_path.abbr());
177+
if link_path.is_dir() && actual_path.is_dir() {
178+
for entry in actual_path.fs_err_read_dir().into_diagnostic()? {
179+
let entry = entry.into_diagnostic()?;
180+
deployer.symlink_recursive(
181+
&egg_root,
182+
entry.path(),
183+
&link_path.join(entry.file_name()),
184+
)?;
185+
}
186+
return Ok(());
187+
} else if link_path.is_dir() || actual_path.is_dir() {
188+
miette::bail!(
189+
"Conflicting file or directory {} with {}",
190+
actual_path.abbr(),
191+
link_path.abbr()
192+
);
193+
}
194+
}
195+
deployer.create_symlink(&actual_path, &link_path)?;
196+
tracing::info!(
197+
"created symlink {}",
198+
format_symlink(link_path.abbr(), actual_path.abbr()),
199+
);
200+
Ok(())
201+
}
202+
inner(
203+
self,
204+
egg_root.as_ref().to_path_buf(),
205+
actual_path.as_ref().to_path_buf(),
206+
link_path.as_ref().to_path_buf(),
207+
)
208+
}
209+
210+
#[tracing::instrument(skip(actual_path, link_path), fields(
211+
actual_path = actual_path.as_ref().abbr(),
212+
link_path = link_path.as_ref().abbr()
213+
))]
214+
pub fn remove_symlink_recursive(
215+
&mut self,
216+
actual_path: impl AsRef<Path>,
217+
link_path: &impl AsRef<Path>,
218+
) -> miette::Result<()> {
219+
let actual_path = actual_path.as_ref();
220+
let link_path = link_path.as_ref();
221+
if link_path.is_symlink() && link_path.canonical()? == actual_path {
222+
tracing::info!(
223+
"Removing symlink {}",
224+
format_symlink(link_path.abbr(), actual_path.abbr())
225+
);
226+
self.delete_symlink(link_path)?;
227+
} else if link_path.is_dir() && actual_path.is_dir() {
228+
for entry in actual_path.fs_err_read_dir().into_diagnostic()? {
229+
let entry = entry.into_diagnostic()?;
230+
self.remove_symlink_recursive(entry.path(), &link_path.join(entry.file_name()))?;
231+
}
232+
} else if link_path.exists() {
233+
miette::bail!(
234+
help = "Yolk will only try to remove files that are symlinks pointing into the corresponding egg.",
235+
"Tried to remove deployment of {}, but {} doesn't link to it",
236+
actual_path.abbr(),
237+
link_path.abbr()
238+
);
239+
}
240+
Ok(())
241+
}
242+
243+
/// Retry running symlink creation and deletion with root priviledges.
104244
pub fn try_run_elevated(self) -> miette::Result<()> {
105245
if self.missing_permissions_create.is_empty() && self.missing_permissions_remove.is_empty()
106246
{
@@ -181,9 +321,8 @@ pub fn create_symlink(original: impl AsRef<Path>, link: impl AsRef<Path>) -> mie
181321
.into_diagnostic()
182322
.wrap_err_with(|| {
183323
format!(
184-
"Failed to create symlink at {} -> {}",
185-
link.abbr(),
186-
original.abbr()
324+
"Failed to create symlink at {}",
325+
format_symlink(link.abbr(), original.abbr())
187326
)
188327
})?;
189328
Ok(())
@@ -233,3 +372,11 @@ fn try_sudo(args: &[String]) -> miette::Result<()> {
233372
}
234373
Ok(())
235374
}
375+
376+
fn format_symlink(link_path: impl AsRef<Path>, original_path: impl AsRef<Path>) -> String {
377+
format!(
378+
"{} -> {}",
379+
link_path.as_ref().display(),
380+
original_path.as_ref().display()
381+
)
382+
}

src/main.rs

Lines changed: 14 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -238,17 +238,14 @@ fn run_command(args: Args) -> Result<()> {
238238
eggs.sort_by_key(|egg| egg.name().to_string());
239239
for egg in eggs {
240240
let deployed = egg.is_deployed()?;
241-
println!(
242-
"{}",
243-
format!("{} {}", if deployed { "✓" } else { "✗" }, egg.name(),)
244-
.if_supports_color(owo_colors::Stream::Stdout, |text| {
245-
text.color(if deployed {
246-
owo_colors::AnsiColors::Green
247-
} else {
248-
owo_colors::AnsiColors::Default
249-
})
250-
})
251-
);
241+
let text = format!("{} {}", if deployed { "✓" } else { "✗" }, egg.name());
242+
let text = text.if_supports_color(owo_colors::Stream::Stdout, |text| {
243+
text.color(match deployed {
244+
true => owo_colors::AnsiColors::Green,
245+
false => owo_colors::AnsiColors::Default,
246+
})
247+
});
248+
println!("{}", text);
252249
}
253250
}
254251
Command::Sync { canonical } => {
@@ -278,9 +275,6 @@ fn run_command(args: Args) -> Result<()> {
278275
command,
279276
force_canonical,
280277
} => {
281-
// TODO: Do I really want this? probably not, tbh
282-
// yolk.validate_config_invariants()?;
283-
//
284278
let mut cmd = yolk.paths().start_git()?.start_git_command_builder();
285279
cmd.args(command);
286280
// if the command is `git push`, we don't need to enter canonical state
@@ -380,7 +374,7 @@ fn run_command(args: Args) -> Result<()> {
380374
let mut debouncer = new_debouncer(
381375
std::time::Duration::from_millis(800),
382376
None,
383-
move |res: DebounceEventResult| {
377+
move |debounce_event_res: DebounceEventResult| {
384378
let mut eval_ctx = match yolk.prepare_eval_ctx_for_templates(mode) {
385379
Ok(x) => x,
386380
Err(e) => {
@@ -405,7 +399,7 @@ fn run_command(args: Args) -> Result<()> {
405399
}
406400
};
407401

408-
match res {
402+
match debounce_event_res {
409403
Ok(events) => {
410404
let changed = events
411405
.into_iter()
@@ -418,18 +412,16 @@ fn run_command(args: Args) -> Result<()> {
418412
})
419413
.flat_map(|x| x.paths.clone().into_iter())
420414
.collect::<HashSet<_>>();
415+
// If yolk.rhai changed, we need to re-evaluate all files.
416+
// This means either just sync_to_mode, or, if no_sync is set, manually calling on_file_updated for each file.
421417
if changed.contains(&yolk.paths().yolk_rhai_path()) {
422418
if no_sync {
423-
for file in files_to_watch.iter() {
424-
on_file_updated(file);
425-
}
419+
files_to_watch.iter().for_each(|file| on_file_updated(file));
426420
} else if let Err(e) = yolk.sync_to_mode(mode, true) {
427421
eprintln!("Error: {e:?}");
428422
}
429423
} else {
430-
for path in changed {
431-
on_file_updated(&path);
432-
}
424+
changed.iter().for_each(|path| on_file_updated(&path));
433425
}
434426
}
435427
Err(error) => tracing::error!("Error: {error:?}"),

src/util.rs

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -137,6 +137,27 @@ impl Path {
137137
}
138138
self.to_path_buf()
139139
}
140+
141+
#[track_caller]
142+
fn assert_absolute(&self, name: &str) {
143+
assert!(
144+
self.is_absolute(),
145+
"Path {} must be absolute, but was: {}",
146+
name,
147+
self.display()
148+
);
149+
}
150+
151+
#[track_caller]
152+
fn assert_starts_with(&self, start: impl AsRef<Path>, name: &str) {
153+
assert!(
154+
self.starts_with(start.as_ref()),
155+
"Path {} must be inside {}, but was: {}",
156+
name,
157+
start.as_ref().display(),
158+
self.display()
159+
);
160+
}
140161
}
141162

142163
pub fn create_regex(s: impl AsRef<str>) -> miette::Result<Regex> {

0 commit comments

Comments
 (0)