|
|
… |
10 unchanged lines |
| 11 |
11 |
|
pub notes: Vec<String>, |
| 12 |
12 |
|
} |
| 13 |
13 |
|
|
|
14 |
+ |
/// Text files rendered at a time by a checkout: bounds its memory. |
|
15 |
+ |
const CHECKOUT_BATCH: usize = 2048; |
|
16 |
+ |
|
| 14 |
17 |
|
impl Repo { |
| 15 |
18 |
|
/// Move the working copy to the state whose heads are `heads` (spec §12.3): |
| 16 |
19 |
|
/// the working directory is rewritten to that state and a fresh |
|
|
… |
17 unchanged lines |
| 34 |
37 |
|
keep_subrepos: &BTreeSet<String>, |
| 35 |
38 |
|
) -> Result<CheckoutReport> { |
| 36 |
39 |
|
let _wc = self.lock_working_copy()?; |
| 37 |
|
- |
let snap = self.snapshot(command)?; |
|
40 |
+ |
// A status snapshot: files the manifest proves unchanged are neither |
|
41 |
+ |
// read nor rendered, and its base is history only (oxygen#35). |
|
42 |
+ |
let snap = self.snapshot_status(command)?; |
| 38 |
43 |
|
if !discard && (!snap.wc.tree_ops.is_empty() || !snap.wc.edits.is_empty()) { |
| 39 |
44 |
|
let list: Vec<String> = snap |
| 40 |
45 |
|
.statuses |
|
|
… |
7 unchanged lines |
| 48 |
53 |
|
if snap.statuses.len() > 8 { ", …" } else { "" } |
| 49 |
54 |
|
)); |
| 50 |
55 |
|
} |
| 51 |
|
- |
let target_state = self.load_state(heads)?; |
| 52 |
|
- |
let heads = target_state.maximal(heads); |
| 53 |
|
- |
let target = target_state.materialize(); |
| 54 |
|
- |
let attrs = Repo::state_attributes(&target); |
| 55 |
|
- |
let current = self.walk_working_copy()?; |
|
56 |
+ |
let old_manifest = self.read_manifest()?.map(|(_, _, m)| m).unwrap_or_default(); |
|
57 |
+ |
let base = &snap.base; |
|
58 |
+ |
let base_files = &snap.base_snapshot.files; |
|
59 |
+ |
let changed: HashSet<&str> = snap.statuses.iter().map(|s| s.path.as_str()).collect(); |
|
60 |
+ |
// The target's history; its file graphs are built a batch at a time |
|
61 |
+ |
// for the files that must be written, and dropped after. |
|
62 |
+ |
let mut target = self.load_state_history(heads)?; |
|
63 |
+ |
let heads = target.maximal(heads); |
|
64 |
+ |
let tview = target.tree().view(); |
| 56 |
65 |
|
let mut report = CheckoutReport::default(); |
| 57 |
66 |
|
// Paths this file system cannot hold side by side (spec §7.7). |
| 58 |
67 |
|
report.notes.extend( |
| 59 |
68 |
|
self.platform |
| 60 |
|
- |
.problems(target.files.keys().map(String::as_str)), |
|
69 |
+ |
.problems(tview.entries.keys().map(String::as_str)), |
| 61 |
70 |
|
); |
|
71 |
+ |
report.conflicted = tview.is_conflicted(); |
| 62 |
72 |
|
|
| 63 |
|
- |
// Remove tracked paths that the target does not have. A nested |
| 64 |
|
- |
// repository is never deleted (spec §7.8); it is left in place. |
|
73 |
+ |
// Remove what the base or the working copy has and the target does |
|
74 |
+ |
// not. A nested repository is never deleted (spec §7.8). |
| 65 |
75 |
|
let mut dirs_to_prune: BTreeSet<PathBuf> = BTreeSet::new(); |
| 66 |
|
- |
for (path, entry) in ¤t { |
| 67 |
|
- |
if !target.files.contains_key(path) { |
| 68 |
|
- |
if entry.kind == NodeKind::Subrepo { |
| 69 |
|
- |
report |
| 70 |
|
- |
.notes |
| 71 |
|
- |
.push(format!("{path}: nested repository left in place")); |
|
76 |
+ |
let gone: BTreeSet<&str> = base_files |
|
77 |
+ |
.iter() |
|
78 |
+ |
.filter(|(_, f)| f.kind != NodeKind::Dir) |
|
79 |
+ |
.map(|(p, _)| p.as_str()) |
|
80 |
+ |
.chain(changed.iter().copied()) |
|
81 |
+ |
.filter(|p| !tview.entries.contains_key(*p)) |
|
82 |
+ |
.collect(); |
|
83 |
+ |
for path in gone { |
|
84 |
+ |
if base_files |
|
85 |
+ |
.get(path) |
|
86 |
+ |
.is_some_and(|f| f.kind == NodeKind::Subrepo) |
|
87 |
+ |
{ |
|
88 |
+ |
report |
|
89 |
+ |
.notes |
|
90 |
+ |
.push(format!("{path}: nested repository left in place")); |
|
91 |
+ |
continue; |
|
92 |
+ |
} |
|
93 |
+ |
let full = self.safe_path(path)?; |
|
94 |
+ |
if fs::symlink_metadata(&full).is_err() { |
|
95 |
+ |
continue; |
|
96 |
+ |
} |
|
97 |
+ |
fs::remove_file(&full)?; |
|
98 |
+ |
report.removed += 1; |
|
99 |
+ |
if let Some(parent) = full.parent() { |
|
100 |
+ |
dirs_to_prune.insert(parent.to_path_buf()); |
|
101 |
+ |
} |
|
102 |
+ |
} |
|
103 |
+ |
|
|
104 |
+ |
// The manifest after the checkout, built from what is known. |
|
105 |
+ |
let mut entries: BTreeMap<String, FsEntry> = BTreeMap::new(); |
|
106 |
+ |
let hex = |b: &[u8]| blake3::hash(b).to_hex().to_string(); |
|
107 |
+ |
// Text files to render, attribute files first (the others need them). |
|
108 |
+ |
let mut texts: Vec<(String, NodeId, Mode)> = Vec::new(); |
|
109 |
+ |
for (path, e) in &tview.entries { |
|
110 |
+ |
if e.kind == NodeKind::Dir || !self.in_scope(path) { |
|
111 |
+ |
continue; |
|
112 |
+ |
} |
|
113 |
+ |
let untouched = !changed.contains(path.as_str()) |
|
114 |
+ |
&& base_files |
|
115 |
+ |
.get(path) |
|
116 |
+ |
.is_some_and(|b| b.node == e.node && b.kind == e.kind && b.mode == e.mode); |
|
117 |
+ |
if e.kind == NodeKind::File && !e.mode.opaque { |
|
118 |
+ |
// Unchanged on disk (unrendered by the snapshot) and the same |
|
119 |
+ |
// graph in both states: nothing to read, render or write. |
|
120 |
+ |
let same_graph = untouched |
|
121 |
+ |
&& snap.base_snapshot.unrendered.contains(path) |
|
122 |
+ |
&& !is_attributes(path) |
|
123 |
+ |
&& base.graph_key(&e.node).is_some() |
|
124 |
+ |
&& base.graph_key(&e.node) == target.graph_key(&e.node); |
|
125 |
+ |
match old_manifest.entries.get(path).filter(|_| same_graph) { |
|
126 |
+ |
Some(m) => { |
|
127 |
+ |
entries.insert( |
|
128 |
+ |
path.clone(), |
|
129 |
+ |
FsEntry { |
|
130 |
+ |
content_hash: Some(m.hash.clone()), |
|
131 |
+ |
graph_key: m.graph.clone(), |
|
132 |
+ |
unchanged: true, |
|
133 |
+ |
..FsEntry::new(NodeKind::File, e.mode.exec, Vec::new()) |
|
134 |
+ |
}, |
|
135 |
+ |
); |
|
136 |
+ |
} |
|
137 |
+ |
None => texts.push((path.clone(), e.node, e.mode)), |
|
138 |
+ |
} |
|
139 |
+ |
continue; |
|
140 |
+ |
} |
|
141 |
+ |
// Opaque files, symlinks and subrepos: their blobs. |
|
142 |
+ |
let blobs = target.live_blobs(&e.node); |
|
143 |
+ |
if blobs.len() > 1 { |
|
144 |
+ |
report.conflicted = true; |
|
145 |
+ |
} |
|
146 |
+ |
let Some(blob) = blobs.first().copied() else { |
|
147 |
+ |
continue; |
|
148 |
+ |
}; |
|
149 |
+ |
if e.kind == NodeKind::Subrepo { |
|
150 |
+ |
if keep_subrepos.contains(path) { |
| 72 |
151 |
|
continue; |
| 73 |
152 |
|
} |
| 74 |
|
- |
let full = self.safe_path(path)?; |
| 75 |
|
- |
fs::remove_file(&full)?; |
| 76 |
|
- |
report.removed += 1; |
| 77 |
|
- |
if let Some(parent) = full.parent() { |
| 78 |
|
- |
dirs_to_prune.insert(parent.to_path_buf()); |
|
153 |
+ |
let pin = crate::subrepo::Pin::decode(&self.read_blob(&blob)?)?; |
|
154 |
+ |
let sync = self.sync_subrepo(path, &pin, false)?; |
|
155 |
+ |
if matches!( |
|
156 |
+ |
sync, |
|
157 |
+ |
SubrepoSync::Updated | SubrepoSync::KeptBookmark { .. } |
|
158 |
+ |
) { |
|
159 |
+ |
report.written += 1; |
| 79 |
160 |
|
} |
|
161 |
+ |
report.notes.extend(sync.note(path)); |
|
162 |
+ |
continue; |
| 80 |
163 |
|
} |
| 81 |
|
- |
} |
| 82 |
|
- |
// Write target files that differ (only inside the sparse filter). |
| 83 |
|
- |
for (path, f) in &target.files { |
| 84 |
|
- |
if !self.in_scope(path) { |
|
164 |
+ |
let same_blob = untouched && base.live_blobs(&e.node) == blobs; |
|
165 |
+ |
if let Some(m) = old_manifest |
|
166 |
+ |
.entries |
|
167 |
+ |
.get(path) |
|
168 |
+ |
.filter(|_| same_blob && blobs.len() == 1) |
|
169 |
+ |
{ |
|
170 |
+ |
entries.insert( |
|
171 |
+ |
path.clone(), |
|
172 |
+ |
FsEntry { |
|
173 |
+ |
content_hash: Some(m.hash.clone()), |
|
174 |
+ |
blob: (e.kind == NodeKind::File).then_some(blob), |
|
175 |
+ |
..FsEntry::new(e.kind, e.mode.exec, Vec::new()) |
|
176 |
+ |
}, |
|
177 |
+ |
); |
| 85 |
178 |
|
continue; |
| 86 |
179 |
|
} |
| 87 |
|
- |
match &f.content { |
| 88 |
|
- |
FileContent::Dir => continue, |
| 89 |
|
- |
FileContent::Text { bytes, .. } => { |
| 90 |
|
- |
let a = attrs.lookup(path); |
| 91 |
|
- |
let on_disk: std::borrow::Cow<[u8]> = |
| 92 |
|
- |
if a.eol == Some(crate::attributes::Eol::Crlf) { |
| 93 |
|
- |
crate::attributes::to_crlf(bytes).into() |
| 94 |
|
- |
} else { |
| 95 |
|
- |
bytes.as_slice().into() |
| 96 |
|
- |
}; |
| 97 |
|
- |
let same = current.get(path).is_some_and(|c| { |
| 98 |
|
- |
c.kind == NodeKind::File |
| 99 |
|
- |
&& c.content == *on_disk |
| 100 |
|
- |
&& (!self.platform.exec_bits || c.exec == f.mode.exec) |
| 101 |
|
- |
}); |
| 102 |
|
- |
if !same { |
| 103 |
|
- |
self.write_file(path, &on_disk, f.mode.exec)?; |
| 104 |
|
- |
report.written += 1; |
| 105 |
|
- |
} |
| 106 |
|
- |
if a.lockable && !self.is_locked(path) { |
| 107 |
|
- |
let _ = set_readonly(&self.root.join(path), true); |
| 108 |
|
- |
} |
|
180 |
+ |
let bytes = self.read_blob(&blob)?; |
|
181 |
+ |
let full = self.root.join(path); |
|
182 |
+ |
if e.kind == NodeKind::Symlink { |
|
183 |
+ |
let same = fs::read_link(&full) |
|
184 |
+ |
.is_ok_and(|t| t.to_string_lossy().as_bytes() == bytes.as_slice()); |
|
185 |
+ |
if !same { |
|
186 |
+ |
self.write_symlink(path, &bytes)?; |
|
187 |
+ |
report.written += 1; |
| 109 |
188 |
|
} |
| 110 |
|
- |
FileContent::Opaque { blobs } => { |
| 111 |
|
- |
let Some(blob) = blobs.first() else { continue }; |
| 112 |
|
- |
let bytes = self.read_blob(blob)?; |
| 113 |
|
- |
if f.kind == NodeKind::Subrepo { |
| 114 |
|
- |
let pin = crate::subrepo::Pin::decode(&bytes)?; |
| 115 |
|
- |
if keep_subrepos.contains(path) { |
| 116 |
|
- |
continue; |
| 117 |
|
- |
} |
| 118 |
|
- |
let sync = self.sync_subrepo(path, &pin, false)?; |
| 119 |
|
- |
if matches!( |
| 120 |
|
- |
sync, |
| 121 |
|
- |
SubrepoSync::Updated | SubrepoSync::KeptBookmark { .. } |
| 122 |
|
- |
) { |
| 123 |
|
- |
report.written += 1; |
| 124 |
|
- |
} |
| 125 |
|
- |
report.notes.extend(sync.note(path)); |
| 126 |
|
- |
continue; |
| 127 |
|
- |
} |
| 128 |
|
- |
if f.kind == NodeKind::Symlink { |
| 129 |
|
- |
let same = current |
| 130 |
|
- |
.get(path) |
| 131 |
|
- |
.is_some_and(|c| c.kind == NodeKind::Symlink && c.content == bytes); |
| 132 |
|
- |
if !same { |
| 133 |
|
- |
self.write_symlink(path, &bytes)?; |
| 134 |
|
- |
report.written += 1; |
| 135 |
|
- |
} |
| 136 |
|
- |
} else { |
| 137 |
|
- |
let same = current.get(path).is_some_and(|c| { |
| 138 |
|
- |
c.kind == NodeKind::File |
| 139 |
|
- |
&& c.content == bytes |
| 140 |
|
- |
&& (!self.platform.exec_bits || c.exec == f.mode.exec) |
| 141 |
|
- |
}); |
| 142 |
|
- |
if !same { |
| 143 |
|
- |
self.write_file(path, &bytes, f.mode.exec)?; |
| 144 |
|
- |
report.written += 1; |
| 145 |
|
- |
} |
| 146 |
|
- |
if attrs.lookup(path).lockable && !self.is_locked(path) { |
| 147 |
|
- |
let _ = set_readonly(&self.root.join(path), true); |
| 148 |
|
- |
} |
| 149 |
|
- |
} |
| 150 |
|
- |
if blobs.len() > 1 { |
| 151 |
|
- |
for (i, b) in blobs.iter().enumerate().skip(1) { |
| 152 |
|
- |
self.write_file( |
| 153 |
|
- |
&format!("{path}.oxy-conflict-{}", i + 1), |
| 154 |
|
- |
&self.read_blob(b)?, |
| 155 |
|
- |
f.mode.exec, |
| 156 |
|
- |
)?; |
| 157 |
|
- |
} |
| 158 |
|
- |
} |
|
189 |
+ |
entries.insert( |
|
190 |
+ |
path.clone(), |
|
191 |
+ |
FsEntry { |
|
192 |
+ |
content_hash: Some(hex(&bytes)), |
|
193 |
+ |
..FsEntry::new(NodeKind::Symlink, false, Vec::new()) |
|
194 |
+ |
}, |
|
195 |
+ |
); |
|
196 |
+ |
} else { |
|
197 |
+ |
if !self.same_on_disk(&full, &bytes, e.mode.exec) { |
|
198 |
+ |
self.write_file(path, &bytes, e.mode.exec)?; |
|
199 |
+ |
report.written += 1; |
|
200 |
+ |
} |
|
201 |
+ |
entries.insert( |
|
202 |
+ |
path.clone(), |
|
203 |
+ |
FsEntry { |
|
204 |
+ |
content_hash: Some(hex(&bytes)), |
|
205 |
+ |
blob: (blobs.len() == 1).then_some(blob), |
|
206 |
+ |
..FsEntry::new(NodeKind::File, e.mode.exec, Vec::new()) |
|
207 |
+ |
}, |
|
208 |
+ |
); |
|
209 |
+ |
} |
|
210 |
+ |
for (i, b) in blobs.iter().enumerate().skip(1) { |
|
211 |
+ |
self.write_file( |
|
212 |
+ |
&format!("{path}.oxy-conflict-{}", i + 1), |
|
213 |
+ |
&self.read_blob(b)?, |
|
214 |
+ |
e.mode.exec, |
|
215 |
+ |
)?; |
|
216 |
+ |
} |
|
217 |
+ |
} |
|
218 |
+ |
|
|
219 |
+ |
// Render and write text files a batch at a time; attribute files |
|
220 |
+ |
// come first, since the others' line endings and locks follow them. |
|
221 |
+ |
texts.sort_by_key(|(p, _, _)| !is_attributes(p)); |
|
222 |
+ |
let keys: HashMap<NodeId, [u8; 32]> = texts |
|
223 |
+ |
.iter() |
|
224 |
+ |
.filter_map(|(_, n, _)| Some((*n, target.graph_key(n)?))) |
|
225 |
+ |
.collect(); |
|
226 |
+ |
let mut attrs = crate::attributes::Attributes::default(); |
|
227 |
+ |
let attr_count = texts.iter().filter(|(p, _, _)| is_attributes(p)).count(); |
|
228 |
+ |
let mut done = 0usize; |
|
229 |
+ |
let mut batches: Vec<&[(String, NodeId, Mode)]> = Vec::new(); |
|
230 |
+ |
if attr_count > 0 { |
|
231 |
+ |
batches.push(&texts[..attr_count]); |
|
232 |
+ |
} |
|
233 |
+ |
batches.extend(texts[attr_count..].chunks(CHECKOUT_BATCH)); |
|
234 |
+ |
for batch in batches { |
|
235 |
+ |
use rayon::prelude::*; |
|
236 |
+ |
let nodes: Vec<NodeId> = batch.iter().map(|(_, n, _)| *n).collect(); |
|
237 |
+ |
self.load_graphs_of(&mut target, &nodes)?; |
|
238 |
+ |
let rendered: Vec<(Vec<u8>, bool)> = nodes |
|
239 |
+ |
.par_iter() |
|
240 |
+ |
.map(|n| { |
|
241 |
+ |
let (bytes, m) = target.render_file(n); |
|
242 |
+ |
(bytes, m.is_conflicted()) |
|
243 |
+ |
}) |
|
244 |
+ |
.collect(); |
|
245 |
+ |
target.drop_graphs(&nodes); |
|
246 |
+ |
if done == 0 && attr_count > 0 { |
|
247 |
+ |
attrs = crate::attributes::Attributes::from_files( |
|
248 |
+ |
batch |
|
249 |
+ |
.iter() |
|
250 |
+ |
.zip(&rendered) |
|
251 |
+ |
.map(|((p, _, _), (b, _))| (p.as_str(), b.as_slice())), |
|
252 |
+ |
); |
|
253 |
+ |
} |
|
254 |
+ |
done += batch.len(); |
|
255 |
+ |
for ((path, node, mode), (bytes, conflicted)) in batch.iter().zip(rendered) { |
|
256 |
+ |
let a = attrs.lookup(path); |
|
257 |
+ |
let crlf = a.eol == Some(crate::attributes::Eol::Crlf); |
|
258 |
+ |
let on_disk: Vec<u8> = if crlf { |
|
259 |
+ |
crate::attributes::to_crlf(&bytes) |
|
260 |
+ |
} else { |
|
261 |
+ |
bytes |
|
262 |
+ |
}; |
|
263 |
+ |
if !self.same_on_disk(&self.root.join(path), &on_disk, mode.exec) { |
|
264 |
+ |
self.write_file(path, &on_disk, mode.exec)?; |
|
265 |
+ |
report.written += 1; |
| 159 |
266 |
|
} |
|
267 |
+ |
report.conflicted |= conflicted; |
|
268 |
+ |
entries.insert( |
|
269 |
+ |
path.clone(), |
|
270 |
+ |
FsEntry { |
|
271 |
+ |
content_hash: Some(hex(&on_disk)), |
|
272 |
+ |
graph_key: (!conflicted && a.eol.is_none()) |
|
273 |
+ |
.then(|| keys.get(node).map(|k| ObjectRef(*k).to_hex())) |
|
274 |
+ |
.flatten(), |
|
275 |
+ |
..FsEntry::new(NodeKind::File, mode.exec, Vec::new()) |
|
276 |
+ |
}, |
|
277 |
+ |
); |
| 160 |
278 |
|
} |
| 161 |
279 |
|
} |
|
280 |
+ |
// Lockable files are read-only until locked (spec §18), whether |
|
281 |
+ |
// written now or left as they were. |
|
282 |
+ |
for (path, e) in &entries { |
|
283 |
+ |
if e.kind == NodeKind::File && attrs.lookup(path).lockable && !self.is_locked(path) { |
|
284 |
+ |
let _ = set_readonly(&self.root.join(path), true); |
|
285 |
+ |
} |
|
286 |
+ |
} |
| 162 |
287 |
|
// Prune directories left empty, deepest first. |
| 163 |
288 |
|
for d in dirs_to_prune.iter().rev() { |
| 164 |
289 |
|
let mut d = d.clone(); |
|
|
… |
14 unchanged lines |
| 179 |
304 |
|
let mut view = op.view.clone(); |
| 180 |
305 |
|
view.workspaces.insert(self.workspace, wc_hash); |
| 181 |
306 |
|
self.record_op(&[op_ref], command, view)?; |
| 182 |
|
- |
report.conflicted = target.is_conflicted(); |
| 183 |
|
- |
let blobs: HashMap<String, ObjectRef> = target |
| 184 |
|
- |
.files |
| 185 |
|
- |
.iter() |
| 186 |
|
- |
.filter_map(|(p, f)| match &f.content { |
| 187 |
|
- |
FileContent::Opaque { blobs } if f.kind == NodeKind::File && blobs.len() == 1 => { |
| 188 |
|
- |
Some((p.clone(), blobs[0])) |
| 189 |
|
- |
} |
| 190 |
|
- |
_ => None, |
| 191 |
|
- |
}) |
| 192 |
|
- |
.collect(); |
| 193 |
|
- |
let entries = self.walk_working_copy_with(None, &Self::tracked_kinds(&target), None)?; |
| 194 |
|
- |
self.write_manifest_entries(report.conflicted, false, &entries, &blobs)?; |
|
307 |
+ |
self.write_manifest_entries(report.conflicted, false, &entries, &HashMap::new())?; |
| 195 |
308 |
|
let _ = self.run_hook( |
| 196 |
309 |
|
"post-checkout", |
| 197 |
310 |
|
&format!( |
|
|
… |
4 unchanged lines |
| 202 |
315 |
|
), |
| 203 |
316 |
|
); |
| 204 |
317 |
|
Ok(report) |
|
318 |
+ |
} |
|
319 |
+ |
|
|
320 |
+ |
/// Whether the file at `full` already holds `bytes` with this executable |
|
321 |
+ |
/// bit, so writing it again can be skipped. |
|
322 |
+ |
fn same_on_disk(&self, full: &Path, bytes: &[u8], exec: bool) -> bool { |
|
323 |
+ |
match fs::symlink_metadata(full) { |
|
324 |
+ |
Ok(m) if m.is_file() && m.len() == bytes.len() as u64 => { |
|
325 |
+ |
(!self.platform.exec_bits || is_executable(&m) == exec) |
|
326 |
+ |
&& fs::read(full).is_ok_and(|b| b == bytes) |
|
327 |
+ |
} |
|
328 |
+ |
_ => false, |
|
329 |
+ |
} |
| 205 |
330 |
|
} |
| 206 |
331 |
|
|
| 207 |
332 |
|
/// The absolute path for a repository path, refusing anything that would |
|
|
… |
79 unchanged lines |