perf: avoid eager diff loading and full-file typing work
This commit is contained in:
+297
-122
@@ -297,10 +297,18 @@ struct Remote {
|
||||
struct FileData {
|
||||
mode: String,
|
||||
bytes: Vec<u8>,
|
||||
object: Option<(PathBuf, String)>,
|
||||
notice: Option<String>,
|
||||
}
|
||||
type Snapshot = BTreeMap<PathBuf, FileData>;
|
||||
|
||||
struct PreparedComparison {
|
||||
id: u64,
|
||||
left: Snapshot,
|
||||
right: Snapshot,
|
||||
files: Vec<(PathBuf, Option<PathBuf>, FileStatus)>,
|
||||
}
|
||||
|
||||
pub struct Repository {
|
||||
root: PathBuf,
|
||||
kind: RepoKind,
|
||||
@@ -311,6 +319,7 @@ pub struct Repository {
|
||||
remote_refs: BTreeMap<(String, String), String>,
|
||||
loaded: BTreeSet<String>,
|
||||
registered: BTreeSet<String>,
|
||||
comparison: Option<PreparedComparison>,
|
||||
cancelled: Arc<AtomicBool>,
|
||||
}
|
||||
impl Repository {
|
||||
@@ -348,6 +357,7 @@ impl Repository {
|
||||
remote_refs: BTreeMap::new(),
|
||||
loaded: BTreeSet::new(),
|
||||
registered: BTreeSet::new(),
|
||||
comparison: None,
|
||||
cancelled,
|
||||
})
|
||||
}
|
||||
@@ -562,42 +572,53 @@ impl Repository {
|
||||
self.revisions(root, store, &mut entries, &mut notices)?;
|
||||
}
|
||||
for remote in &self.remotes {
|
||||
entries.push(Revision {
|
||||
target: RevisionTarget::FetchRemote(remote.name.clone()),
|
||||
label: format!("Load history from {}", remote.name),
|
||||
description: "Fetch reachable history into a private comparison repository".into(),
|
||||
category,
|
||||
});
|
||||
match self.advertised(&remote.url) {
|
||||
Ok(refs) => {
|
||||
self.remote_refs.retain(|(name, _), _| name != &remote.name);
|
||||
for (reference, id) in refs {
|
||||
let tag = reference.starts_with("refs/tags/");
|
||||
let name = reference
|
||||
.strip_prefix(if tag { "refs/tags/" } else { "refs/heads/" })
|
||||
.unwrap();
|
||||
if !tag || self.kind == RepoKind::Git {
|
||||
entries.push(Revision {
|
||||
target: RevisionTarget::Remote {
|
||||
remote: remote.name.clone(),
|
||||
reference: reference.clone(),
|
||||
},
|
||||
label: format!("{name}@{}", remote.name),
|
||||
description: id.clone(),
|
||||
category: if tag {
|
||||
Category::Tags
|
||||
} else if self.kind == RepoKind::Jujutsu {
|
||||
Category::Bookmarks
|
||||
} else {
|
||||
Category::Branches
|
||||
},
|
||||
});
|
||||
}
|
||||
self.remote_refs
|
||||
.insert((remote.name.clone(), reference), id);
|
||||
for &view in self.kind.categories() {
|
||||
let history = view == category;
|
||||
entries.push(Revision {
|
||||
target: if history {
|
||||
RevisionTarget::FetchRemote(remote.name.clone())
|
||||
} else {
|
||||
RevisionTarget::ListRemote(remote.name.clone())
|
||||
},
|
||||
label: format!(
|
||||
"Load {} from {}",
|
||||
if history { "history" } else { "refs" },
|
||||
remote.name
|
||||
),
|
||||
description: if history {
|
||||
"Fetch reachable history into a private comparison repository"
|
||||
} else {
|
||||
"List remote references without fetching history"
|
||||
}
|
||||
.into(),
|
||||
category: view,
|
||||
});
|
||||
}
|
||||
for ((name, reference), id) in &self.remote_refs {
|
||||
if name != &remote.name {
|
||||
continue;
|
||||
}
|
||||
let tag = reference.starts_with("refs/tags/");
|
||||
let label = reference
|
||||
.strip_prefix(if tag { "refs/tags/" } else { "refs/heads/" })
|
||||
.unwrap();
|
||||
if !tag || self.kind == RepoKind::Git {
|
||||
entries.push(Revision {
|
||||
target: RevisionTarget::Remote {
|
||||
remote: name.clone(),
|
||||
reference: reference.clone(),
|
||||
},
|
||||
label: format!("{label}@{name}"),
|
||||
description: id.clone(),
|
||||
category: if tag {
|
||||
Category::Tags
|
||||
} else if self.kind == RepoKind::Jujutsu {
|
||||
Category::Bookmarks
|
||||
} else {
|
||||
Category::Branches
|
||||
},
|
||||
});
|
||||
}
|
||||
Err(e) => notices.push(format!("{}: {e}", remote.name)),
|
||||
}
|
||||
}
|
||||
self.check_cancelled()?;
|
||||
@@ -645,7 +666,7 @@ impl Repository {
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
fn load_remote(&mut self, name: &str) -> Result<(), String> {
|
||||
fn load_remote(&mut self, name: &str, reference: Option<&str>) -> Result<(), String> {
|
||||
if self.remotes.is_empty() {
|
||||
self.discover_remotes()?;
|
||||
}
|
||||
@@ -674,7 +695,27 @@ impl Repository {
|
||||
"--remote",
|
||||
&format!("exact:{name}"),
|
||||
"--branch",
|
||||
"glob:*",
|
||||
&reference.map_or_else(
|
||||
|| Ok("glob:*".to_owned()),
|
||||
|reference| {
|
||||
reference
|
||||
.strip_prefix("refs/heads/")
|
||||
.map(|branch| format!("exact:{branch}"))
|
||||
.ok_or("Jujutsu remote selection requires a bookmark")
|
||||
},
|
||||
)?,
|
||||
],
|
||||
)?;
|
||||
} else if let Some(reference) = reference {
|
||||
self.git(
|
||||
store,
|
||||
&[
|
||||
"fetch",
|
||||
"--no-tags",
|
||||
"--no-write-fetch-head",
|
||||
"--",
|
||||
&url,
|
||||
&format!("+{reference}:refs/vamp-selected/{name}/{reference}"),
|
||||
],
|
||||
)?;
|
||||
} else {
|
||||
@@ -690,16 +731,33 @@ impl Repository {
|
||||
],
|
||||
)?;
|
||||
}
|
||||
self.loaded.insert(name.into());
|
||||
let refs = self.advertised(&url)?;
|
||||
self.remote_refs.retain(|(remote, _), _| remote != name);
|
||||
for (reference, id) in refs {
|
||||
self.remote_refs.insert((name.into(), reference), id);
|
||||
if reference.is_none() {
|
||||
self.loaded.insert(name.into());
|
||||
let refs = self.advertised(&url)?;
|
||||
self.remote_refs.retain(|(remote, _), _| remote != name);
|
||||
for (reference, id) in refs {
|
||||
self.remote_refs.insert((name.into(), reference), id);
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
pub fn list_remote(&mut self, name: &str) -> Result<Catalog, String> {
|
||||
self.discover_remotes()?;
|
||||
let remote = self
|
||||
.remotes
|
||||
.iter()
|
||||
.find(|remote| remote.name == name)
|
||||
.ok_or_else(|| format!("Unknown configured remote: {name}"))?;
|
||||
let refs = self.advertised(&remote.url)?;
|
||||
self.remote_refs.retain(|(remote, _), _| remote != name);
|
||||
self.remote_refs.extend(
|
||||
refs.into_iter()
|
||||
.map(|(reference, id)| ((name.into(), reference), id)),
|
||||
);
|
||||
self.catalog()
|
||||
}
|
||||
pub fn fetch_remote(&mut self, remote: &str) -> Result<Catalog, String> {
|
||||
self.load_remote(remote)?;
|
||||
self.load_remote(remote, None)?;
|
||||
self.catalog()
|
||||
}
|
||||
fn resolve(&mut self, target: &RevisionTarget) -> Result<(PathBuf, String), String> {
|
||||
@@ -719,22 +777,33 @@ impl Repository {
|
||||
}
|
||||
id.clone()
|
||||
}
|
||||
RevisionTarget::Remote { remote, reference } => {
|
||||
if !self.loaded.contains(remote) {
|
||||
self.load_remote(remote)?;
|
||||
}
|
||||
self.remote_refs
|
||||
.get(&(remote.clone(), reference.clone()))
|
||||
.ok_or_else(|| {
|
||||
format!("Remote reference is no longer advertised: {reference}@{remote}")
|
||||
})?
|
||||
.clone()
|
||||
}
|
||||
RevisionTarget::Remote { remote, reference } => self
|
||||
.remote_refs
|
||||
.get(&(remote.clone(), reference.clone()))
|
||||
.ok_or_else(|| {
|
||||
format!("Remote reference is no longer advertised: {reference}@{remote}")
|
||||
})?
|
||||
.clone(),
|
||||
RevisionTarget::Working => return Err("Working checkout is not a commit".into()),
|
||||
RevisionTarget::FetchRemote(_) => {
|
||||
RevisionTarget::FetchRemote(_) | RevisionTarget::ListRemote(_) => {
|
||||
return Err("Select a revision, not a history loader".into());
|
||||
}
|
||||
};
|
||||
if let Some(resolved) = self.resolve_stored(&id)? {
|
||||
return Ok(resolved);
|
||||
}
|
||||
if let RevisionTarget::Remote { remote, reference } = target {
|
||||
self.load_remote(remote, Some(reference))?;
|
||||
if let Some(resolved) = self.resolve_stored(&id)? {
|
||||
return Ok(resolved);
|
||||
}
|
||||
}
|
||||
Err(format!(
|
||||
"Commit {id} is unavailable locally or in loaded remote history"
|
||||
))
|
||||
}
|
||||
|
||||
fn resolve_stored(&self, id: &str) -> Result<Option<(PathBuf, String)>, String> {
|
||||
for store in
|
||||
std::iter::once(&self.store).chain(self.private.as_ref().map(|(_, store)| store))
|
||||
{
|
||||
@@ -743,15 +812,13 @@ impl Repository {
|
||||
.current_dir(&self.root);
|
||||
let output = run(cmd, vec![], &self.cancelled)?;
|
||||
if output.code == Some(0) {
|
||||
return Ok((store.clone(), text(output.stdout)?.trim().into()));
|
||||
return Ok(Some((store.clone(), text(output.stdout)?.trim().into())));
|
||||
}
|
||||
if !matches!(output.code, Some(1 | 128)) {
|
||||
checked(output)?;
|
||||
}
|
||||
}
|
||||
Err(format!(
|
||||
"Commit {id} is unavailable locally or in loaded remote history"
|
||||
))
|
||||
Ok(None)
|
||||
}
|
||||
fn conflicted_snapshot(&self, root: &Path, id: &str) -> Result<Snapshot, String> {
|
||||
let revset = format!("commit_id({})", serde_json::to_string(id).unwrap());
|
||||
@@ -820,6 +887,7 @@ impl Repository {
|
||||
FileData {
|
||||
mode: mode.into(),
|
||||
bytes,
|
||||
object: None,
|
||||
notice,
|
||||
},
|
||||
);
|
||||
@@ -853,8 +921,7 @@ impl Repository {
|
||||
}
|
||||
}
|
||||
let output = self.git(store, &["ls-tree", "-r", "-z", "--full-tree", id])?;
|
||||
let mut entries = vec![];
|
||||
let mut request = Vec::new();
|
||||
let mut snapshot = BTreeMap::new();
|
||||
for record in output.split(|b| *b == 0).filter(|r| !r.is_empty()) {
|
||||
let tab = record
|
||||
.iter()
|
||||
@@ -868,32 +935,60 @@ impl Repository {
|
||||
}
|
||||
let path = path_bytes(&record[tab + 1..])?;
|
||||
safe_path(&path)?;
|
||||
if fields[1] == "commit" {
|
||||
entries.push((
|
||||
path,
|
||||
fields[0].to_owned(),
|
||||
let (object, notice) = if fields[1] == "commit" {
|
||||
(
|
||||
None,
|
||||
Some(format!(
|
||||
"Submodule commit {} (contents are not traversed)",
|
||||
fields[2]
|
||||
)),
|
||||
));
|
||||
)
|
||||
} else if fields[1] == "blob" {
|
||||
request.extend_from_slice(fields[2].as_bytes());
|
||||
request.push(b'\n');
|
||||
entries.push((path, fields[0].to_owned(), Some(fields[2].to_owned()), None));
|
||||
(Some((store.to_path_buf(), fields[2].to_owned())), None)
|
||||
} else {
|
||||
return Err(format!("Unsupported tree entry type: {}", fields[1]));
|
||||
}
|
||||
};
|
||||
snapshot.insert(
|
||||
path,
|
||||
FileData {
|
||||
mode: fields[0].into(),
|
||||
bytes: vec![],
|
||||
object,
|
||||
notice,
|
||||
},
|
||||
);
|
||||
}
|
||||
let mut cmd = git_command(store);
|
||||
cmd.arg("cat-file").arg("--batch");
|
||||
let data = checked(run(cmd, request, &self.cancelled)?)?;
|
||||
let mut offset = 0;
|
||||
let mut snapshot = BTreeMap::new();
|
||||
for (path, mode, object, notice) in entries {
|
||||
self.check_cancelled()?;
|
||||
let bytes = if object.is_some() {
|
||||
Ok(snapshot)
|
||||
}
|
||||
|
||||
fn materialize(&self, snapshot: &mut Snapshot) -> Result<(), String> {
|
||||
let stores: BTreeSet<_> = snapshot
|
||||
.values()
|
||||
.filter_map(|file| file.object.as_ref().map(|(store, _)| store.clone()))
|
||||
.collect();
|
||||
for store in stores {
|
||||
let mut request = Vec::new();
|
||||
for file in snapshot.values() {
|
||||
if let Some((source, id)) = &file.object
|
||||
&& source == &store
|
||||
{
|
||||
request.extend_from_slice(id.as_bytes());
|
||||
request.push(b'\n');
|
||||
}
|
||||
}
|
||||
let mut cmd = git_command(&store);
|
||||
cmd.args(["cat-file", "--batch"]);
|
||||
let data = checked(run(cmd, request, &self.cancelled)?)?;
|
||||
let mut offset = 0;
|
||||
for (path, file) in snapshot.iter_mut() {
|
||||
if file
|
||||
.object
|
||||
.as_ref()
|
||||
.is_none_or(|(source, _)| source != &store)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
self.check_cancelled()?;
|
||||
let end = offset
|
||||
+ data[offset..]
|
||||
.iter()
|
||||
@@ -917,22 +1012,12 @@ impl Repository {
|
||||
if size >= data.len().saturating_sub(offset) {
|
||||
return Err("Truncated Git object content".into());
|
||||
}
|
||||
let bytes = data[offset..offset + size].to_vec();
|
||||
file.bytes = data[offset..offset + size].to_vec();
|
||||
file.object = None;
|
||||
offset += size + 1;
|
||||
bytes
|
||||
} else {
|
||||
vec![]
|
||||
};
|
||||
snapshot.insert(
|
||||
path,
|
||||
FileData {
|
||||
mode,
|
||||
bytes,
|
||||
notice,
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
Ok(snapshot)
|
||||
Ok(())
|
||||
}
|
||||
fn working_snapshot(&self, buffers: &[(PathBuf, String)]) -> Result<Snapshot, String> {
|
||||
let mut tracked = BTreeSet::new();
|
||||
@@ -1100,6 +1185,7 @@ impl Repository {
|
||||
FileData {
|
||||
mode: "160000".into(),
|
||||
bytes: vec![],
|
||||
object: None,
|
||||
notice: Some(notice),
|
||||
},
|
||||
);
|
||||
@@ -1178,6 +1264,7 @@ impl Repository {
|
||||
FileData {
|
||||
mode,
|
||||
bytes,
|
||||
object: None,
|
||||
notice,
|
||||
},
|
||||
);
|
||||
@@ -1192,38 +1279,72 @@ impl Repository {
|
||||
self.commit_snapshot(&store, &id)
|
||||
}
|
||||
}
|
||||
/// Freeze comparison sources and return path/status metadata only.
|
||||
/// A new comparison replaces sources retained for the previous request ID.
|
||||
pub fn compare(
|
||||
&mut self,
|
||||
comparison_id: u64,
|
||||
before: &Revision,
|
||||
after: &Revision,
|
||||
buffers: &[(PathBuf, String)],
|
||||
) -> Result<Comparison, String> {
|
||||
let left = self.snapshot(&before.target)?;
|
||||
self.comparison = None;
|
||||
let mut left = self.snapshot(&before.target)?;
|
||||
let working_after = after.target == RevisionTarget::Working;
|
||||
let right = if before.target == after.target && (!working_after || buffers.is_empty()) {
|
||||
None
|
||||
let mut right = if before.target == after.target && (!working_after || buffers.is_empty()) {
|
||||
BTreeMap::new()
|
||||
} else if working_after {
|
||||
Some(self.working_snapshot(buffers)?)
|
||||
self.working_snapshot(buffers)?
|
||||
} else {
|
||||
Some(self.snapshot(&after.target)?)
|
||||
self.snapshot(&after.target)?
|
||||
};
|
||||
let right = right.as_ref().unwrap_or(&left);
|
||||
let same = before.target == after.target && (!working_after || buffers.is_empty());
|
||||
// Frozen checkout/conflict bytes have no object identity. Compare them in a
|
||||
// single batch, while immutable tree/tree comparisons need only object IDs.
|
||||
let frozen = |file: &FileData| {
|
||||
file.object.is_none() && matches!(file.mode.as_str(), "100644" | "100755" | "120000")
|
||||
};
|
||||
if !same && (left.values().any(frozen) || right.values().any(frozen)) {
|
||||
self.materialize(&mut left)?;
|
||||
self.materialize(&mut right)?;
|
||||
}
|
||||
let right_view = if same { &left } else { &right };
|
||||
let mut files = vec![];
|
||||
let paths: BTreeSet<_> = left.keys().chain(right.keys()).collect();
|
||||
let paths: BTreeSet<_> = left.keys().chain(right_view.keys()).collect();
|
||||
for path in paths {
|
||||
self.check_cancelled()?;
|
||||
let a = left.get(path);
|
||||
let b = right.get(path);
|
||||
if a == b {
|
||||
let b = right_view.get(path);
|
||||
if a.map(file_identity) == b.map(file_identity) {
|
||||
continue;
|
||||
}
|
||||
files.push(self.changed_file(path, a, b)?);
|
||||
files.push(ChangedFile {
|
||||
path: path.clone(),
|
||||
old_path: None,
|
||||
status: if a.is_none() {
|
||||
FileStatus::Added
|
||||
} else if b.is_none() {
|
||||
FileStatus::Removed
|
||||
} else {
|
||||
FileStatus::Modified
|
||||
},
|
||||
loaded: false,
|
||||
before: vec![],
|
||||
after: vec![],
|
||||
before_eof: false,
|
||||
after_eof: false,
|
||||
hunks: vec![],
|
||||
notice: None,
|
||||
});
|
||||
}
|
||||
// Match only identical data; modified-content moves remain add/remove pairs.
|
||||
let mut candidates: BTreeMap<&FileData, Vec<usize>> = BTreeMap::new();
|
||||
let mut candidates = BTreeMap::new();
|
||||
for (i, file) in files.iter().enumerate() {
|
||||
if file.status == FileStatus::Removed {
|
||||
candidates.entry(&left[&file.path]).or_default().push(i);
|
||||
candidates
|
||||
.entry(file_identity(&left[&file.path]))
|
||||
.or_insert_with(Vec::new)
|
||||
.push(i);
|
||||
}
|
||||
}
|
||||
let mut removed = BTreeSet::new();
|
||||
@@ -1232,18 +1353,11 @@ impl Repository {
|
||||
continue;
|
||||
}
|
||||
if let Some(j) = candidates
|
||||
.get_mut(&right[&files[i].path])
|
||||
.get_mut(&file_identity(&right_view[&files[i].path]))
|
||||
.and_then(Vec::pop)
|
||||
{
|
||||
let old_path = files[j].path.clone();
|
||||
let mut renamed = self.changed_file(
|
||||
&files[i].path,
|
||||
left.get(&old_path),
|
||||
right.get(&files[i].path),
|
||||
)?;
|
||||
renamed.status = FileStatus::Renamed;
|
||||
renamed.old_path = Some(old_path);
|
||||
files[i] = renamed;
|
||||
files[i].status = FileStatus::Renamed;
|
||||
files[i].old_path = Some(files[j].path.clone());
|
||||
removed.insert(j);
|
||||
}
|
||||
}
|
||||
@@ -1256,21 +1370,24 @@ impl Repository {
|
||||
files
|
||||
.iter()
|
||||
.filter(|file| {
|
||||
right.get(&file.path).is_some_and(|data| {
|
||||
matches!(data.mode.as_str(), "100644" | "100755")
|
||||
&& !data.bytes.contains(&0)
|
||||
&& std::str::from_utf8(&data.bytes).is_ok()
|
||||
&& file
|
||||
.notice
|
||||
.as_deref()
|
||||
.is_none_or(|notice| !notice.contains("Binary or non-UTF-8"))
|
||||
})
|
||||
right_view
|
||||
.get(&file.path)
|
||||
.is_some_and(|data| matches!(data.mode.as_str(), "100644" | "100755"))
|
||||
})
|
||||
.map(|file| file.path.clone())
|
||||
.collect()
|
||||
} else {
|
||||
BTreeSet::new()
|
||||
};
|
||||
self.comparison = Some(PreparedComparison {
|
||||
id: comparison_id,
|
||||
left,
|
||||
right,
|
||||
files: files
|
||||
.iter()
|
||||
.map(|file| (file.path.clone(), file.old_path.clone(), file.status))
|
||||
.collect(),
|
||||
});
|
||||
Ok(Comparison {
|
||||
before: before.label.clone(),
|
||||
after: after.label.clone(),
|
||||
@@ -1279,6 +1396,45 @@ impl Repository {
|
||||
editable_paths,
|
||||
})
|
||||
}
|
||||
|
||||
/// Prepare one catalog entry using its original comparison's frozen sources.
|
||||
pub fn load_file(
|
||||
&mut self,
|
||||
comparison_id: u64,
|
||||
file_index: usize,
|
||||
) -> Result<ChangedFile, String> {
|
||||
self.check_cancelled()?;
|
||||
let prepared = self
|
||||
.comparison
|
||||
.as_ref()
|
||||
.filter(|comparison| comparison.id == comparison_id)
|
||||
.ok_or("Comparison is no longer available")?;
|
||||
let (path, old_path, status) = prepared
|
||||
.files
|
||||
.get(file_index)
|
||||
.ok_or("Changed file is no longer available")?;
|
||||
let before_path = old_path.as_ref().unwrap_or(path);
|
||||
let mut left = BTreeMap::new();
|
||||
let mut right = BTreeMap::new();
|
||||
let before = prepared.left.get(before_path);
|
||||
let after = prepared.right.get(path);
|
||||
if let Some(data) = before.filter(|data| data.object.is_some()) {
|
||||
left.insert(before_path.clone(), clone_file_data(data));
|
||||
}
|
||||
if let Some(data) = after.filter(|data| data.object.is_some()) {
|
||||
right.insert(path.clone(), clone_file_data(data));
|
||||
}
|
||||
self.materialize(&mut left)?;
|
||||
self.materialize(&mut right)?;
|
||||
let mut file = self.changed_file(
|
||||
path,
|
||||
left.get(before_path).or(before),
|
||||
right.get(path).or(after),
|
||||
)?;
|
||||
file.status = *status;
|
||||
file.old_path = old_path.clone();
|
||||
Ok(file)
|
||||
}
|
||||
fn changed_file(
|
||||
&self,
|
||||
path: &Path,
|
||||
@@ -1337,6 +1493,7 @@ impl Repository {
|
||||
path: path.to_path_buf(),
|
||||
old_path: None,
|
||||
status,
|
||||
loaded: true,
|
||||
before: before_lines,
|
||||
after: after_lines,
|
||||
before_eof: a.ends_with(b"\n"),
|
||||
@@ -1350,6 +1507,24 @@ impl Repository {
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
fn file_identity(file: &FileData) -> (&str, Option<&str>, &[u8], Option<&str>) {
|
||||
(
|
||||
&file.mode,
|
||||
file.object.as_ref().map(|(_, id)| id.as_str()),
|
||||
&file.bytes,
|
||||
file.notice.as_deref(),
|
||||
)
|
||||
}
|
||||
|
||||
fn clone_file_data(file: &FileData) -> FileData {
|
||||
FileData {
|
||||
mode: file.mode.clone(),
|
||||
bytes: file.bytes.clone(),
|
||||
object: file.object.clone(),
|
||||
notice: file.notice.clone(),
|
||||
}
|
||||
}
|
||||
fn lines(bytes: &[u8]) -> Vec<String> {
|
||||
if bytes.is_empty() {
|
||||
return vec![];
|
||||
|
||||
Reference in New Issue
Block a user