Projects coming from Git: .gitignore honoured, nested Git repositories named (closes #43, closes #44) d4dbab97e948
- A directory's .gitignore and .ignore files are read with its .oxyignore, below it: .oxyignore can bring back with ! what they leave out. Ignore rules apply to untracked paths only, so the walk no longer matches the base's files against them (on the Linux tree, with its 405 .gitignore files, that kept status at its speed), while a file added but not committed is still dropped by a rule written after it. - A folder holding .git or .oxy that is not a subrepo is now named by status (clean or not), commit and status --json (untracked_nested), with how to track its files or keep it a subrepo. - The clean check finds tracked files the ignore rules hide by merging the sorted listing with the manifest, and checks them in parallel, rather than stat-ing every manifest entry. Spec and guide say so.
- Author
- Andy McCall @andymccall <[email protected]>
- When
- · 2026-10-01 00:29 UTC
- Signature
- ✔ signed by Andy McCall
- Hash
d4dbab97e9481a95cd2b7bb47747f967e3eee85da7397703a77692aa688f34a9- Depends on
- 135212de5370 1966f5db15ee 4dee277c327a 50a046e439e9 75167672ebcd 76740263089c 957fee30826e 982864068a59 9ac1fbe211b4 c6aa5bd9d1c8 cdb5beba7f05 da868d5d2646 e266b7d4065c eaf3e9068180 ee06a6c557b7 f9466021286a fce44ae9ad29 ff0af1ee299d
- Depended on by
- 232aabb5452d 64dc5197f9df 4afe288b7946 44f1ebe7aa5b 9b723f69b032 b689779e9e74 7505194116e7 80677b7a3513 236f35c0baba 0389c3e82f6c 3604179679eb 2bf22305bc48 fd280228bdc2 69692f14b80f
- In bookmarks
- agents-in-readme bare-hashes carried-not-removed carry-identical doctor-trust identical-sides keyless-tags main merge-remote-bookmark only-help-103 paths-for-resolutions-and-amend push-notes show-stat subrepo-pin-state switch-says
- In tags
- v0.10.0 v0.11.0 v0.2.0 v0.3.0 v0.3.1 v0.4.0 v0.4.1 v0.4.2 v0.4.3 v0.4.4 v0.4.5 v0.4.6 v0.5.0 v0.5.1 v0.6.0 v0.7.0 v0.8.0 v0.9.0
- Edits
- 41 atoms, 0 tree operations, 9 files
Browse the tree at this change
modified crates/oxy-core/src/repo/mod.rs +21 −1
| … | 202 unchanged lines |
||
| 203 | 203 | /// snapshot that follows it to reuse; taken by the next walk and |
|
| 204 | 204 | /// dropped whenever the manifest is written. |
|
| 205 | 205 | pub(crate) listing_cache: std::cell::RefCell<Option<walk::Listing>>, |
|
| 206 | + | /// The nested repositories the last walk left out, being neither |
|
| 207 | + | /// registered nor tracked subrepos (oxygen#43). |
|
| 208 | + | pub(crate) untracked_nested: std::cell::RefCell<Vec<String>>, |
|
| 206 | 209 | root: PathBuf, |
|
| 207 | 210 | oxy: PathBuf, |
|
| 208 | 211 | store: Store, |
|
| … | 76 unchanged lines |
||
| 285 | 288 | git_export_skipped: Default::default(), |
|
| 286 | 289 | manifest_cache: Default::default(), |
|
| 287 | 290 | listing_cache: Default::default(), |
|
| 291 | + | untracked_nested: Default::default(), |
|
| 288 | 292 | root, |
|
| 289 | 293 | oxy, |
|
| 290 | 294 | store, |
|
| … | 46 unchanged lines |
||
| 337 | 341 | git_export_skipped: Default::default(), |
|
| 338 | 342 | manifest_cache: Default::default(), |
|
| 339 | 343 | listing_cache: Default::default(), |
|
| 344 | + | untracked_nested: Default::default(), |
|
| 340 | 345 | root, |
|
| 341 | 346 | oxy, |
|
| 342 | 347 | store, |
|
| … | 68 unchanged lines |
||
| 411 | 416 | git_export_skipped: Default::default(), |
|
| 412 | 417 | manifest_cache: Default::default(), |
|
| 413 | 418 | listing_cache: Default::default(), |
|
| 419 | + | untracked_nested: Default::default(), |
|
| 414 | 420 | root: d.to_path_buf(), |
|
| 415 | 421 | oxy: shared, |
|
| 416 | 422 | store, |
|
| … | 23 unchanged lines |
||
| 440 | 446 | git_export_skipped: Default::default(), |
|
| 441 | 447 | manifest_cache: Default::default(), |
|
| 442 | 448 | listing_cache: Default::default(), |
|
| 449 | + | untracked_nested: Default::default(), |
|
| 443 | 450 | root: d.to_path_buf(), |
|
| 444 | 451 | oxy, |
|
| 445 | 452 | store, |
|
| … | 651 unchanged lines |
||
| 1097 | 1104 | ) |
|
| 1098 | 1105 | } |
|
| 1099 | 1106 | ||
| 1107 | + | /// Folders holding a repository of their own that the last look at the |
|
| 1108 | + | /// working copy left out, not being subrepos (oxygen#43). |
|
| 1109 | + | pub fn untracked_nested(&self) -> Vec<String> { |
|
| 1110 | + | self.untracked_nested.borrow().clone() |
|
| 1111 | + | } |
|
| 1112 | + | ||
| 1100 | 1113 | /// Attributes from a materialized state's `.oxyattributes` files. |
|
| 1101 | 1114 | pub fn state_attributes(snapshot: &Snapshot) -> crate::attributes::Attributes { |
|
| 1102 | 1115 | crate::attributes::Attributes::from_files(snapshot.files.iter().filter_map(|(p, f)| { |
|
| … | 261 unchanged lines |
||
| 1364 | 1377 | nested, |
|
| 1365 | 1378 | } = match self.listing_cache.take() { |
|
| 1366 | 1379 | Some(l) => l, |
|
| 1367 | - | None => walk::list(&self.root, &in_scope)?, |
|
| 1380 | + | // Ignore rules apply to untracked paths only, so the base's |
|
| 1381 | + | // files are not matched against them (an added file is: a rule |
|
| 1382 | + | // written after it was snapshotted leaves it out again). |
|
| 1383 | + | None => walk::list_knowing(&self.root, &in_scope, &|p: &str| tracked.contains_key(p))?, |
|
| 1368 | 1384 | }; |
|
| 1369 | 1385 | for (rel, target) in symlinks { |
|
| 1370 | 1386 | out.insert(rel, FsEntry::new(NodeKind::Symlink, false, target)); |
|
| … | 54 unchanged lines |
||
| 1425 | 1441 | self.clear_removed_subrepo(rel); |
|
| 1426 | 1442 | } |
|
| 1427 | 1443 | } |
|
| 1444 | + | let mut untracked_nested = Vec::new(); |
|
| 1428 | 1445 | for dir in nested { |
|
| 1429 | 1446 | let rel = self.rel_path(&dir); |
|
| 1430 | 1447 | if !self.in_scope(&rel) || removed.contains(&rel) { |
|
| … | 2 unchanged lines |
||
| 1433 | 1450 | let registered = modules.entries.get(&rel); |
|
| 1434 | 1451 | if registered.is_none() && tracked.get(&rel).map(|(k, _)| *k) != Some(NodeKind::Subrepo) |
|
| 1435 | 1452 | { |
|
| 1453 | + | untracked_nested.push(rel); |
|
| 1436 | 1454 | continue; |
|
| 1437 | 1455 | } |
|
| 1438 | 1456 | let pin = subrepos::read_pin(&dir, registered.and_then(|m| m.bookmark.as_deref()))?; |
|
| 1439 | 1457 | out.insert(rel, FsEntry::new(NodeKind::Subrepo, false, pin.encode())); |
|
| 1440 | 1458 | } |
|
| 1459 | + | untracked_nested.sort(); |
|
| 1460 | + | *self.untracked_nested.borrow_mut() = untracked_nested; |
|
| 1441 | 1461 | // Tracked paths the ignore rules hid. |
|
| 1442 | 1462 | for (rel, (kind, blob)) in tracked { |
|
| 1443 | 1463 | if out.contains_key(rel) || !self.in_scope(rel) { |
|
| … | 486 unchanged lines |
modified crates/oxy-core/src/repo/prompt.rs +46 −18
| … | 479 unchanged lines |
||
| 480 | 480 | .any(|s| p == s || p.starts_with(&format!("{s}/"))) |
|
| 481 | 481 | }; |
|
| 482 | 482 | // A working copy that cannot be listed counts as changed. |
|
| 483 | - | let Ok(listing) = super::walk::list(&self.root, &in_scope) else { |
|
| 483 | + | // Known files are not matched against the ignore rules: with no |
|
| 484 | + | // uncommitted work (the clean check), the manifest's files are the |
|
| 485 | + | // base's, and a prompt's answer is the same either way. |
|
| 486 | + | let known = |p: &str| manifest.entries.contains_key(p); |
|
| 487 | + | let Ok(listing) = super::walk::list_knowing(&self.root, &in_scope, &known) else { |
|
| 484 | 488 | return Ok(Some(true)); |
|
| 485 | 489 | }; |
|
| 486 | 490 | let differs = || -> Result<bool> { |
|
| … | 24 unchanged lines |
||
| 511 | 515 | if seen == manifest.entries.len() { |
|
| 512 | 516 | return Ok(false); |
|
| 513 | 517 | } |
|
| 514 | - | // Manifest entries the walk skipped: tracked files that ignore rules |
|
| 515 | - | // hide. They count as present when still on disk and unchanged. |
|
| 516 | - | for (rel, me) in &manifest.entries { |
|
| 517 | - | let path = self.root.join(rel); |
|
| 518 | + | // Manifest entries the walk skipped: tracked files that ignore |
|
| 519 | + | // rules hide (Linux tracks some its own .gitignore names). Found |
|
| 520 | + | // by merging the two sorted lists; they count as present when |
|
| 521 | + | // still on disk and unchanged, checked on every core. |
|
| 522 | + | let mut listed: Vec<&str> = listing |
|
| 523 | + | .files |
|
| 524 | + | .iter() |
|
| 525 | + | .map(|(r, _, _)| r.as_str()) |
|
| 526 | + | .chain(listing.symlinks.iter().map(|(r, _)| r.as_str())) |
|
| 527 | + | .collect(); |
|
| 528 | + | listed.sort_unstable(); |
|
| 529 | + | let mut at = listed.iter().peekable(); |
|
| 530 | + | let skipped: Vec<(&String, &ManifestEntry)> = manifest |
|
| 531 | + | .entries |
|
| 532 | + | .iter() |
|
| 533 | + | .filter(|(rel, _)| { |
|
| 534 | + | while at.next_if(|l| **l < rel.as_str()).is_some() {} |
|
| 535 | + | at.peek().is_none_or(|l| **l != rel.as_str()) |
|
| 536 | + | }) |
|
| 537 | + | .collect(); |
|
| 538 | + | let root = &self.root; |
|
| 539 | + | Ok(skipped.par_iter().any(|(rel, me)| { |
|
| 540 | + | let path = root.join(rel.as_str()); |
|
| 518 | 541 | let Ok(m) = fs::symlink_metadata(&path) else { |
|
| 519 | - | return Ok(true); |
|
| 542 | + | return true; |
|
| 520 | 543 | }; |
|
| 521 | - | if m.file_type().is_symlink() { |
|
| 522 | - | continue; |
|
| 523 | - | } |
|
| 524 | - | if m.len() != me.size |
|
| 525 | - | || super::is_executable(&m) != me.exec |
|
| 526 | - | || (mtime_ns(&m) != me.mtime_ns |
|
| 527 | - | && *blake3::hash(&fs::read(&path)?).as_bytes() != me.hash) |
|
| 528 | - | { |
|
| 529 | - | return Ok(true); |
|
| 530 | - | } |
|
| 531 | - | } |
|
| 532 | - | Ok(false) |
|
| 544 | + | !m.file_type().is_symlink() |
|
| 545 | + | && (m.len() != me.size |
|
| 546 | + | || super::is_executable(&m) != me.exec |
|
| 547 | + | || (mtime_ns(&m) != me.mtime_ns |
|
| 548 | + | && !fs::read(&path) |
|
| 549 | + | .is_ok_and(|b| *blake3::hash(&b).as_bytes() == me.hash))) |
|
| 550 | + | })) |
|
| 533 | 551 | }; |
|
| 534 | 552 | let dirty = differs()?; |
|
| 553 | + | if !dirty { |
|
| 554 | + | // Nested repositories the manifest has no entry for are not |
|
| 555 | + | // subrepos: nothing in them is tracked (oxygen#43). |
|
| 556 | + | *self.untracked_nested.borrow_mut() = listing |
|
| 557 | + | .nested |
|
| 558 | + | .iter() |
|
| 559 | + | .map(|d| self.rel_path(d)) |
|
| 560 | + | .filter(|rel| !manifest.entries.contains_key(rel)) |
|
| 561 | + | .collect(); |
|
| 562 | + | } |
|
| 535 | 563 | if dirty && keep { |
|
| 536 | 564 | *self.listing_cache.borrow_mut() = Some(listing); |
|
| 537 | 565 | } |
|
| … | 78 unchanged lines |
modified crates/oxy-core/src/repo/snapshot.rs +7 −2
| … | 58 unchanged lines |
||
| 59 | 59 | pub contributors: Vec<ChangeHash>, |
|
| 60 | 60 | /// The `.oxyattributes` in force on disk, from the files the walk read. |
|
| 61 | 61 | pub(crate) attributes: crate::attributes::Attributes, |
|
| 62 | + | /// Folders holding a repository of their own (`.git` or `.oxy`) that |
|
| 63 | + | /// are not subrepos, so nothing in them is tracked (oxygen#43). |
|
| 64 | + | pub untracked_nested: Vec<String>, |
|
| 62 | 65 | } |
|
| 63 | 66 | ||
| 64 | 67 | pub fn is_binary(content: &[u8]) -> bool { |
|
| … | 433 unchanged lines |
||
| 498 | 501 | resolves, |
|
| 499 | 502 | contributors, |
|
| 500 | 503 | attributes, |
|
| 504 | + | untracked_nested: self.untracked_nested.borrow().clone(), |
|
| 501 | 505 | }) |
|
| 502 | 506 | } |
|
| 503 | 507 | ||
| … | 89 unchanged lines |
||
| 593 | 597 | )); |
|
| 594 | 598 | } |
|
| 595 | 599 | Ok(format!( |
|
| 596 | - | "{{\"bookmark\":{},\"working_copy\":{},\"heads\":{},\"clean\":{},\"conflicted\":{},\"paths\":{paths},\"conflicts\":{}}}", |
|
| 600 | + | "{{\"bookmark\":{},\"working_copy\":{},\"heads\":{},\"clean\":{},\"conflicted\":{},\"paths\":{paths},\"conflicts\":{},\"untracked_nested\":{}}}", |
|
| 597 | 601 | json_str(&self.config.bookmark), |
|
| 598 | 602 | json_str(&snap.wc.change_id.to_hex()), |
|
| 599 | 603 | heads.len(), |
|
| 600 | 604 | snap.statuses.is_empty(), |
|
| 601 | 605 | base_snapshot.is_conflicted(), |
|
| 602 | - | json_array(conflicts) |
|
| 606 | + | json_array(conflicts), |
|
| 607 | + | json_array(snap.untracked_nested.iter().map(|p| json_str(p))) |
|
| 603 | 608 | )) |
|
| 604 | 609 | } |
|
| 605 | 610 | ||
| … | 1985 unchanged lines |
modified crates/oxy-core/src/repo/tests.rs +23 −0
| … | 6395 unchanged lines |
||
| 6396 | 6396 | "{err}" |
|
| 6397 | 6397 | ); |
|
| 6398 | 6398 | } |
|
| 6399 | + | ||
| 6400 | + | #[test] |
|
| 6401 | + | fn an_ignore_rule_drops_an_added_file_but_not_a_tracked_one() { |
|
| 6402 | + | hermetic(); |
|
| 6403 | + | let dir = tempfile::tempdir().unwrap(); |
|
| 6404 | + | let root = dir.path(); |
|
| 6405 | + | let repo = Repo::init(root).unwrap(); |
|
| 6406 | + | write(root, "a.txt", b"a\n"); |
|
| 6407 | + | write(root, "kept.log", b"tracked\n"); |
|
| 6408 | + | repo.commit("first", &[]).unwrap(); |
|
| 6409 | + | write(root, "build.log", b"made\n"); |
|
| 6410 | + | let snap = repo.snapshot_status("status").unwrap(); |
|
| 6411 | + | assert!(snap.statuses.iter().any(|s| s.path == "build.log")); |
|
| 6412 | + | // A rule written afterwards leaves the added file out again, while the |
|
| 6413 | + | // committed one stays tracked. |
|
| 6414 | + | write(root, ".gitignore", b"*.log\n"); |
|
| 6415 | + | let snap = repo.snapshot_status("status").unwrap(); |
|
| 6416 | + | let paths: Vec<&str> = snap.statuses.iter().map(|s| s.path.as_str()).collect(); |
|
| 6417 | + | assert_eq!(paths, vec![".gitignore"], "{paths:?}"); |
|
| 6418 | + | write(root, "kept.log", b"tracked, edited\n"); |
|
| 6419 | + | let snap = repo.snapshot_status("status").unwrap(); |
|
| 6420 | + | assert!(snap.statuses.iter().any(|s| s.path == "kept.log")); |
|
| 6421 | + | } |
modified crates/oxy-core/src/repo/walk.rs +35 −6
| 1 | 1 | //! Listing the working copy (spec §12.3): every file and symlink under the |
|
| 2 | 2 | //! root, found in parallel. `.oxy` is skipped, and so is a nested |
|
| 3 | 3 | //! repository (a directory holding `.oxy` or `.git`), which is reported |
|
| 4 | - | //! instead. `.oxyignore` files (and `.ignore` files, as before) exclude |
|
| 5 | - | //! paths with gitignore syntax, the nearest file deciding. |
|
| 4 | + | //! instead. `.oxyignore` files exclude paths with gitignore syntax, the |
|
| 5 | + | //! nearest file deciding; `.gitignore` and `.ignore` files count too, below |
|
| 6 | + | //! `.oxyignore` in the same directory (oxygen#44), so a project coming from |
|
| 7 | + | //! Git does not commit what Git ignored, and `.oxyignore` can still say |
|
| 8 | + | //! otherwise with `!`. |
|
| 6 | 9 | //! |
|
| 7 | 10 | //! Each directory is read once; a file's stat comes from its directory |
|
| 8 | 11 | //! entry, and results are gathered a directory at a time, so a tree of |
|
| … | 22 unchanged lines |
||
| 31 | 34 | impl Rules { |
|
| 32 | 35 | /// The rules for `dir`, adding its own ignore files if it has any. |
|
| 33 | 36 | fn enter(&self, dir: &Path, names: &[String]) -> Rules { |
|
| 34 | - | let files: Vec<&str> = [".ignore", ".oxyignore"] |
|
| 37 | + | // Later files win: .oxyignore over .ignore over .gitignore. |
|
| 38 | + | let files: Vec<&str> = [".gitignore", ".ignore", ".oxyignore"] |
|
| 35 | 39 | .into_iter() |
|
| 36 | 40 | .filter(|f| names.iter().any(|n| n == f)) |
|
| 37 | 41 | .collect(); |
|
| … | 27 unchanged lines |
||
| 65 | 69 | ||
| 66 | 70 | struct Ctx<'a> { |
|
| 67 | 71 | in_scope: &'a (dyn Fn(&str) -> bool + Sync), |
|
| 72 | + | known: &'a (dyn Fn(&str) -> bool + Sync), |
|
| 68 | 73 | out: Mutex<Listing>, |
|
| 69 | 74 | failed: Mutex<Option<String>>, |
|
| 70 | 75 | } |
|
| … | 1 unchanged lines |
||
| 72 | 77 | /// List the working copy under `root`; files outside `in_scope` (a sparse |
|
| 73 | 78 | /// checkout's paths) are left out, though every directory is visited. |
|
| 74 | 79 | pub(crate) fn list(root: &Path, in_scope: &(dyn Fn(&str) -> bool + Sync)) -> Result<Listing> { |
|
| 80 | + | list_knowing(root, in_scope, &|_| false) |
|
| 81 | + | } |
|
| 82 | + | ||
| 83 | + | /// [`list`], taking files `known` says are tracked without asking the |
|
| 84 | + | /// ignore rules, which apply to untracked paths only: on the Linux tree, |
|
| 85 | + | /// matching every file against its 400 `.gitignore` files cost more than |
|
| 86 | + | /// a manifest lookup. |
|
| 87 | + | pub(crate) fn list_knowing( |
|
| 88 | + | root: &Path, |
|
| 89 | + | in_scope: &(dyn Fn(&str) -> bool + Sync), |
|
| 90 | + | known: &(dyn Fn(&str) -> bool + Sync), |
|
| 91 | + | ) -> Result<Listing> { |
|
| 75 | 92 | let ctx = Ctx { |
|
| 76 | 93 | in_scope, |
|
| 94 | + | known, |
|
| 77 | 95 | out: Mutex::new(Listing::default()), |
|
| 78 | 96 | failed: Mutex::new(None), |
|
| 79 | 97 | }; |
|
| … | 52 unchanged lines |
||
| 132 | 150 | } |
|
| 133 | 151 | let Ok(ft) = e.file_type() else { continue }; |
|
| 134 | 152 | let path = e.path(); |
|
| 135 | - | if rules.ignored(&path, ft.is_dir()) { |
|
| 136 | - | continue; |
|
| 137 | - | } |
|
| 138 | 153 | let child = if rel.is_empty() { |
|
| 139 | 154 | name |
|
| 140 | 155 | } else { |
|
| 141 | 156 | format!("{rel}/{name}") |
|
| 142 | 157 | }; |
|
| 158 | + | if (ft.is_dir() || !(ctx.known)(&child)) && rules.ignored(&path, ft.is_dir()) { |
|
| 159 | + | continue; |
|
| 160 | + | } |
|
| 143 | 161 | if ft.is_dir() { |
|
| 144 | 162 | let rules = rules.clone(); |
|
| 145 | 163 | s.spawn(move |s| visit(s, path, child, false, rules, ctx)); |
|
| … | 76 unchanged lines |
||
| 222 | 240 | write(root, "other/.ignore", "secret\n"); |
|
| 223 | 241 | write(root, "other/secret", ""); |
|
| 224 | 242 | write(root, "other/public", ""); |
|
| 243 | + | // .gitignore counts, below .oxyignore in the same directory. |
|
| 244 | + | write(root, "gitproj/.gitignore", "build/\n*.tmp\nkeep.tmp\n"); |
|
| 245 | + | write(root, "gitproj/.oxyignore", "!keep.tmp\n"); |
|
| 246 | + | write(root, "gitproj/build/out.bin", ""); |
|
| 247 | + | write(root, "gitproj/x.tmp", ""); |
|
| 248 | + | write(root, "gitproj/keep.tmp", ""); |
|
| 249 | + | write(root, "gitproj/src.c", ""); |
|
| 225 | 250 | // Nested repositories are reported, not entered. |
|
| 226 | 251 | write(root, "sub/.oxy/config", ""); |
|
| 227 | 252 | write(root, "sub/inner.txt", ""); |
|
| … | 6 unchanged lines |
||
| 234 | 259 | ".hidden", |
|
| 235 | 260 | ".oxyignore", |
|
| 236 | 261 | "a.txt", |
|
| 262 | + | "gitproj/.gitignore", |
|
| 263 | + | "gitproj/.oxyignore", |
|
| 264 | + | "gitproj/keep.tmp", |
|
| 265 | + | "gitproj/src.c", |
|
| 237 | 266 | "keep/.oxyignore", |
|
| 238 | 267 | "keep/k.log", |
|
| 239 | 268 | "other/.ignore", |
|
| … | 15 unchanged lines |
modified docs/guide.md +3 −1
| … | 47 unchanged lines |
||
| 48 | 48 | directory, negations with `!`) for files that should never be tracked; |
|
| 49 | 49 | `/target` and `*.tmp` are typical entries. As in Git, ignore rules only |
|
| 50 | 50 | affect untracked files: a file already in history stays tracked until you |
|
| 51 | - | delete it. |
|
| 51 | + | delete it. A project's `.gitignore` files are honoured too, so moving a Git |
|
| 52 | + | project to Oxygen does not commit its build output; `.oxyignore` comes |
|
| 53 | + | first, and a `!` rule there brings back what `.gitignore` leaves out. |
|
| 52 | 54 | ||
| 53 | 55 | To take over an existing Git project, see section 12. |
|
| 54 | 56 | ||
| … | 453 unchanged lines |
modified docs/spec.md +1 −1
| … | 805 unchanged lines |
||
| 806 | 806 | ||
| 807 | 807 | | File | Purpose | |
|
| 808 | 808 | |---|---| |
|
| 809 | - | | `.oxyignore` | Ignore rules. Gitignore syntax and semantics, per directory, plus `.oxy/info/ignore` and the global ignore file. Rules apply to untracked paths only: a tracked path stays tracked, and its edits are seen, until it is deleted or removed from the state. | |
|
| 809 | + | | `.oxyignore` | Ignore rules. Gitignore syntax and semantics, per directory, plus `.oxy/info/ignore` and the global ignore file. A directory's `.gitignore` and `.ignore` files are read too, with lower precedence than its `.oxyignore` (a `!` rule there re-includes what they exclude), so a project coming from Git leaves out what Git did. Rules apply to untracked paths only: a tracked path stays tracked, and its edits are seen, until it is deleted or removed from the state. | |
|
| 810 | 810 | | `.oxyattributes` | Per-path attributes: `binary` (force opaque), `text` (text unless NUL bytes), `eol=lf|crlf` (store LF; check out as given), `nocompress` (chunks packed raw), `lockable` (read-only until `oxy lock`), `filter=<name>`. Gitattributes syntax: a pattern without `/` matches basenames below the file's directory, one with `/` is anchored to it; later rules and deeper files override. The attributes in force for a change are those in the files being committed. | |
|
| 811 | 811 | | `.oxy/config` | Repository-local settings, TOML. | |
|
| 812 | 812 | | `~/.config/oxy/config.toml` | User settings, the same keys as `.oxy/config`: `author.name`, `author.email`, `author.key`, `core.sign`, and `remote.<name>.token`. Overridden by `$OXY_HOME/config.toml` when `$OXY_HOME` is set. | |
|
| … | 232 unchanged lines |
modified src/cli/workspace.rs +38 −0
| … | 55 unchanged lines |
||
| 56 | 56 | } |
|
| 57 | 57 | } |
|
| 58 | 58 | ||
| 59 | + | /// Folders holding a repository of their own, which oxy leaves out (oxygen#43): |
|
| 60 | + | /// named, with how to track them. |
|
| 61 | + | fn print_untracked_nested(repo: &Repo, nested: &[String]) { |
|
| 62 | + | if nested.is_empty() { |
|
| 63 | + | return; |
|
| 64 | + | } |
|
| 65 | + | println!(); |
|
| 66 | + | println!( |
|
| 67 | + | "{} {}", |
|
| 68 | + | ui::section(ui::warn("Not tracked")), |
|
| 69 | + | ui::dim("folders holding a repository of their own") |
|
| 70 | + | ); |
|
| 71 | + | for p in nested { |
|
| 72 | + | let kind = if repo.root().join(p).join(".git").exists() { |
|
| 73 | + | "Git" |
|
| 74 | + | } else { |
|
| 75 | + | "Oxygen" |
|
| 76 | + | }; |
|
| 77 | + | println!( |
|
| 78 | + | " {} {} {}", |
|
| 79 | + | ui::caution(), |
|
| 80 | + | ui::path(format!("{p}/")), |
|
| 81 | + | ui::dim(format!("a nested {kind} repository")) |
|
| 82 | + | ); |
|
| 83 | + | } |
|
| 84 | + | println!( |
|
| 85 | + | " {} {}", |
|
| 86 | + | ui::note(), |
|
| 87 | + | ui::dim( |
|
| 88 | + | "to track one's files, remove its .git (or .oxy); to keep it a repository of its own, \ |
|
| 89 | + | oxy submodule add URL PATH" |
|
| 90 | + | ) |
|
| 91 | + | ); |
|
| 92 | + | } |
|
| 93 | + | ||
| 59 | 94 | pub fn status(json: bool) -> Result<i32> { |
|
| 60 | 95 | let repo = open()?; |
|
| 61 | 96 | // Clean by the manifest alone: no history to load (oxygen#35). |
|
| … | 14 unchanged lines |
||
| 76 | 111 | ui::ok(ui::glyph("✔", "*")), |
|
| 77 | 112 | ui::dim("nothing to commit, working copy clean") |
|
| 78 | 113 | ); |
|
| 114 | + | print_untracked_nested(&repo, &repo.untracked_nested()); |
|
| 79 | 115 | return Ok(0); |
|
| 80 | 116 | } |
|
| 81 | 117 | let sp = ui::spinner("Snapshotting the working copy…"); |
|
| … | 37 unchanged lines |
||
| 119 | 155 | ); |
|
| 120 | 156 | } |
|
| 121 | 157 | } |
|
| 158 | + | print_untracked_nested(&repo, &snap.untracked_nested); |
|
| 122 | 159 | if !snap.base_snapshot.is_conflicted() { |
|
| 123 | 160 | return Ok(0); |
|
| 124 | 161 | } |
|
| … | 126 unchanged lines |
||
| 251 | 288 | ui::hash(format!("#{}", h.short(12))), |
|
| 252 | 289 | ui::subject(subject_of(message)) |
|
| 253 | 290 | )); |
|
| 291 | + | print_untracked_nested(&repo, &repo.untracked_nested()); |
|
| 254 | 292 | if !matches!(selection, oxy_core::repo::Selection::All) { |
|
| 255 | 293 | let rest = repo.snapshot_status("oxy commit")?; |
|
| 256 | 294 | if !rest.statuses.is_empty() { |
|
| … | 1666 unchanged lines |
modified tests/cli_json.rs +29 −0
| … | 560 unchanged lines |
||
| 561 | 561 | let (out, _) = status(&r); |
|
| 562 | 562 | assert!(out.contains("c.txt"), "{out}"); |
|
| 563 | 563 | } |
|
| 564 | + | ||
| 565 | + | #[test] |
|
| 566 | + | fn a_nested_git_repository_is_named_not_silently_skipped() { |
|
| 567 | + | let tmp = tempfile::tempdir().unwrap(); |
|
| 568 | + | let home = tmp.path().join("home"); |
|
| 569 | + | std::fs::create_dir_all(&home).unwrap(); |
|
| 570 | + | let r = tmp.path().join("repo"); |
|
| 571 | + | std::fs::create_dir_all(&r).unwrap(); |
|
| 572 | + | assert_eq!(oxy(&r, &home, &["init", "."]).0, 0); |
|
| 573 | + | write(&r, "a.txt", "a\n"); |
|
| 574 | + | write(&r, "rust-core/src/lib.rs", "pub fn f() {}\n"); |
|
| 575 | + | write(&r, "rust-core/.git/HEAD", "ref: refs/heads/main\n"); |
|
| 576 | + | let st = json(&r, &home, &["status", "--json"]); |
|
| 577 | + | assert_eq!(st["untracked_nested"], serde_json::json!(["rust-core"])); |
|
| 578 | + | let (code, out, err) = oxy(&r, &home, &["commit", "-m", "first"]); |
|
| 579 | + | assert_eq!(code, 0, "{err}"); |
|
| 580 | + | let out = String::from_utf8_lossy(&out); |
|
| 581 | + | assert!( |
|
| 582 | + | out.contains("rust-core/") && out.contains("nested Git repository"), |
|
| 583 | + | "{out}" |
|
| 584 | + | ); |
|
| 585 | + | // The clean status says so too. |
|
| 586 | + | let (_, out, _) = oxy(&r, &home, &["status"]); |
|
| 587 | + | let out = String::from_utf8_lossy(&out); |
|
| 588 | + | assert!( |
|
| 589 | + | out.contains("working copy clean") && out.contains("rust-core/"), |
|
| 590 | + | "{out}" |
|
| 591 | + | ); |
|
| 592 | + | } |