submodule remove --delete; agent commits say awaiting seal (closes #164, closes #165) 284fe47d98b9
- Author
- Andy McCall @andymccall <[email protected]>
- via
- agent
agent:claude@andymccall <[email protected]> · keyless, under your grant for changes,refs,seal · key 2d4f51b5260b
- When
- · 2026-11-03 21:54 UTC
- Signature
- ◈ sealed by agent:claude@andymccall
- Hash
284fe47d98b95a8811d65bcf3c101dc26a0024a088c18fa52315c7dc97fb8242- Depends on
- 4dee277c327a 5cde7882d767 75167672ebcd 8fd78eb52ad2 94084087a876 aded4110f0bd c3069537d7c5 c6aa5bd9d1c8 d42369abf180 e59512f2f075 fc1a466a1e66 fce44ae9ad29
- Depended on by
- 232aabb5452d
- In bookmarks
- main
- In tags
- v0.11.0
- Edits
- 18 atoms, 0 tree operations, 5 files
Browse the tree at this change
modified CHANGELOG.md +2 −0
| … | 1 unchanged lines |
||
| 2 | 2 | ||
| 3 | 3 | ## Unreleased |
|
| 4 | 4 | ||
| 5 | + | - **Added: `oxy submodule remove --delete` (#164).** It deletes the subrepo's directory too, read-only object files and all, and refuses while the subrepo has uncommitted edits (`--force` to delete them anyway); without it, removal says the directory is left and how to delete it. A directory moved by hand used to fail halfway, after the copy, on the read-only files. |
|
| 6 | + | - **Changed: an agent's commit says "awaiting seal", not "unsigned" (#165).** In a checkout that signs keyless under an agent grant, a change of its own author that the next push will seal shows `◌ awaiting seal` in `oxy log` and `oxy show`; "unsigned" is kept for changes nothing will sign. |
|
| 5 | 7 | - **Fixed: a stale `author.key` can no longer fork your identity (#163).** A checkout that pinned `author.key` to a key a rotation made elsewhere had already succeeded built a second successor of it on `oxy identity delegate add`, splitting the identity in two. `rotate`, `renew` and `delegate add`/`remove` now refuse to start from a key that already has a successor in your home, naming the newest key and the `oxy config` that points at it; their report says which key they started from and which config named it; and `oxy identity show` warns when the configured key is not the newest, and names the key where an identity forks. |
|
| 6 | 8 | ||
| 7 | 9 | ## 0.10.0 (2026-11-01) |
|
| … | 315 unchanged lines |
modified src/cli/format.rs +25 −3
| … | 41 unchanged lines |
||
| 42 | 42 | ||
| 43 | 43 | /// One line describing how a change is authenticated. |
|
| 44 | 44 | pub fn signature_line(repo: &Repo, h: &ChangeHash) -> Result<String> { |
|
| 45 | - | Ok(match repo.authenticate_change(h)? { |
|
| 45 | + | let status = repo.authenticate_change(h)?; |
|
| 46 | + | let awaiting = repo |
|
| 47 | + | .store() |
|
| 48 | + | .get_change(h) |
|
| 49 | + | .is_ok_and(|c| awaits_seal(repo, &status, &c.author)); |
|
| 50 | + | Ok(match status { |
|
| 51 | + | SignatureStatus::Unsigned if awaiting => { |
|
| 52 | + | "awaiting seal (the next push seals it under the agent's grant)".to_string() |
|
| 53 | + | } |
|
| 46 | 54 | SignatureStatus::Unsigned => "unsigned".to_string(), |
|
| 47 | 55 | SignatureStatus::Valid { |
|
| 48 | 56 | key, |
|
| … | 79 unchanged lines |
||
| 128 | 136 | } |
|
| 129 | 137 | _ => None, |
|
| 130 | 138 | } |
|
| 139 | + | } |
|
| 140 | + | ||
| 141 | + | /// Whether an unsigned change is one the next push will seal: this |
|
| 142 | + | /// checkout signs keyless (an agent under a grant) and the change is its |
|
| 143 | + | /// configured author's (oxygen#165). Read cold, "unsigned" looks like an |
|
| 144 | + | /// error; it is not one until the push. |
|
| 145 | + | pub fn awaits_seal(repo: &Repo, status: &SignatureStatus, author: &oxy_core::hash::KeyId) -> bool { |
|
| 146 | + | matches!(status, SignatureStatus::Unsigned) |
|
| 147 | + | && repo.config.signer_keyless |
|
| 148 | + | && repo.config.author_key == Some(*author) |
|
| 131 | 149 | } |
|
| 132 | 150 | ||
| 133 | 151 | /// A compact, styled authentication marker for log lines, from a status |
|
| 134 | - | /// already in hand (the caller also reads who signed, see [`via`]). |
|
| 135 | - | pub fn signature_mark_of(status: &SignatureStatus) -> String { |
|
| 152 | + | /// already in hand (the caller also reads who signed, see [`via`]); |
|
| 153 | + | /// `awaiting` from [`awaits_seal`]. |
|
| 154 | + | pub fn signature_mark_of(status: &SignatureStatus, awaiting: bool) -> String { |
|
| 136 | 155 | match status { |
|
| 156 | + | SignatureStatus::Unsigned if awaiting => { |
|
| 157 | + | ui::dim(format!("{} awaiting seal", ui::glyph("◌", "o"))) |
|
| 158 | + | } |
|
| 137 | 159 | SignatureStatus::Unsigned => ui::dim(format!("{} unsigned", ui::glyph("·", "-"))), |
|
| 138 | 160 | SignatureStatus::Valid { |
|
| 139 | 161 | sealed, |
|
| … | 64 unchanged lines |
modified src/cli/mod.rs +11 −1
| … | 789 unchanged lines |
||
| 790 | 790 | Remove { |
|
| 791 | 791 | /// The subrepo to unregister |
|
| 792 | 792 | path: String, |
|
| 793 | + | /// Delete its directory too (#164); refused while it has uncommitted edits |
|
| 794 | + | #[arg(long)] |
|
| 795 | + | delete: bool, |
|
| 796 | + | /// With --delete: delete it even with uncommitted edits |
|
| 797 | + | #[arg(long, requires = "delete")] |
|
| 798 | + | force: bool, |
|
| 793 | 799 | }, |
|
| 794 | 800 | } |
|
| 795 | 801 | ||
| … | 686 unchanged lines |
||
| 1482 | 1488 | } => sync::submodule_add(&url, &path, bookmark.as_deref()), |
|
| 1483 | 1489 | SubmoduleCmd::Update { init, paths } => sync::submodule_update(init, &paths), |
|
| 1484 | 1490 | SubmoduleCmd::Status { json } => sync::submodule_status(json), |
|
| 1485 | - | SubmoduleCmd::Remove { path } => sync::submodule_remove(&path), |
|
| 1491 | + | SubmoduleCmd::Remove { |
|
| 1492 | + | path, |
|
| 1493 | + | delete, |
|
| 1494 | + | force, |
|
| 1495 | + | } => sync::submodule_remove(&path, delete, force), |
|
| 1486 | 1496 | }, |
|
| 1487 | 1497 | Cmd::Tlog { cmd } => match cmd { |
|
| 1488 | 1498 | TlogCmd::Verify { |
|
| … | 239 unchanged lines |
modified src/cli/sync.rs +60 −2
| … | 1190 unchanged lines |
||
| 1191 | 1191 | Ok(0) |
|
| 1192 | 1192 | } |
|
| 1193 | 1193 | ||
| 1194 | - | pub fn submodule_remove(path: &str) -> Result<i32> { |
|
| 1194 | + | pub fn submodule_remove(path: &str, delete: bool, force: bool) -> Result<i32> { |
|
| 1195 | 1195 | let repo = open()?; |
|
| 1196 | + | let dir = repo.root().join(path.trim_end_matches('/')); |
|
| 1197 | + | // Look before unregistering, so a refused delete changes nothing. |
|
| 1198 | + | if delete && dir.exists() && !force { |
|
| 1199 | + | if let Ok(sub) = oxy_core::repo::Repo::open(&dir) { |
|
| 1200 | + | let snap = sub.snapshot("oxy submodule remove --delete")?; |
|
| 1201 | + | if !snap.statuses.is_empty() { |
|
| 1202 | + | anyhow::bail!( |
|
| 1203 | + | "{path} has {} with uncommitted edits; commit them in the subrepo (and push them if you want them kept), or pass --force to delete them", |
|
| 1204 | + | plural(snap.statuses.len(), "file") |
|
| 1205 | + | ); |
|
| 1206 | + | } |
|
| 1207 | + | } |
|
| 1208 | + | } |
|
| 1196 | 1209 | repo.submodule_remove(path)?; |
|
| 1210 | + | if !delete { |
|
| 1211 | + | println!( |
|
| 1212 | + | "{} subrepo {} {}", |
|
| 1213 | + | ui::done("Removed"), |
|
| 1214 | + | ui::path(path), |
|
| 1215 | + | ui::dim("(directory left in place; commit to record the removal)") |
|
| 1216 | + | ); |
|
| 1217 | + | println!( |
|
| 1218 | + | " {} {}", |
|
| 1219 | + | ui::note(), |
|
| 1220 | + | ui::dim(format!( |
|
| 1221 | + | "to delete the directory too: oxy submodule remove {path} --delete" |
|
| 1222 | + | )) |
|
| 1223 | + | ); |
|
| 1224 | + | return Ok(0); |
|
| 1225 | + | } |
|
| 1226 | + | if dir.exists() { |
|
| 1227 | + | delete_tree(&dir)?; |
|
| 1228 | + | } |
|
| 1197 | 1229 | println!( |
|
| 1198 | 1230 | "{} subrepo {} {}", |
|
| 1199 | 1231 | ui::done("Removed"), |
|
| 1200 | 1232 | ui::path(path), |
|
| 1201 | - | ui::dim("(directory left in place; commit to record the removal)") |
|
| 1233 | + | ui::dim("and its directory (commit to record the removal)") |
|
| 1202 | 1234 | ); |
|
| 1203 | 1235 | Ok(0) |
|
| 1236 | + | } |
|
| 1237 | + | ||
| 1238 | + | /// Delete a directory tree that holds a repository: its object files are |
|
| 1239 | + | /// read-only, so each is made writable first (#164). |
|
| 1240 | + | fn delete_tree(dir: &std::path::Path) -> Result<()> { |
|
| 1241 | + | fn writable(p: &std::path::Path) -> std::io::Result<()> { |
|
| 1242 | + | let meta = std::fs::symlink_metadata(p)?; |
|
| 1243 | + | if meta.file_type().is_symlink() { |
|
| 1244 | + | return Ok(()); |
|
| 1245 | + | } |
|
| 1246 | + | let mut perms = meta.permissions(); |
|
| 1247 | + | if perms.readonly() { |
|
| 1248 | + | #[allow(clippy::permissions_set_readonly_false)] |
|
| 1249 | + | perms.set_readonly(false); |
|
| 1250 | + | std::fs::set_permissions(p, perms)?; |
|
| 1251 | + | } |
|
| 1252 | + | if meta.is_dir() { |
|
| 1253 | + | for e in std::fs::read_dir(p)? { |
|
| 1254 | + | writable(&e?.path())?; |
|
| 1255 | + | } |
|
| 1256 | + | } |
|
| 1257 | + | Ok(()) |
|
| 1258 | + | } |
|
| 1259 | + | writable(dir).map_err(|e| anyhow::anyhow!("{}: {e}", dir.display()))?; |
|
| 1260 | + | std::fs::remove_dir_all(dir).map_err(|e| anyhow::anyhow!("{}: {e}", dir.display()))?; |
|
| 1261 | + | Ok(()) |
|
| 1204 | 1262 | } |
|
| 1205 | 1263 | ||
| 1206 | 1264 | pub fn deepen(remote: &str, depth: u32) -> Result<i32> { |
|
| … | 34 unchanged lines |
modified src/cli/workspace.rs +6 −3
| 1 | 1 | //! Commands that act on the local repository and working copy. |
|
| 2 | 2 | ||
| 3 | 3 | use super::format::{ |
|
| 4 | - | author_name, deps_line, deps_of, plural, short_id, signature_line, signature_mark_of, |
|
| 5 | - | subject_of, via, when, |
|
| 4 | + | author_name, awaits_seal, deps_line, deps_of, plural, short_id, signature_line, |
|
| 5 | + | signature_mark_of, subject_of, via, when, |
|
| 6 | 6 | }; |
|
| 7 | 7 | use super::json; |
|
| 8 | 8 | use super::open; |
|
| … | 817 unchanged lines |
||
| 826 | 826 | if let Some(agent) = via(&repo, &status, &c.author) { |
|
| 827 | 827 | head.push(ui::dim(format!("via {}", agent.name))); |
|
| 828 | 828 | } |
|
| 829 | - | head.push(signature_mark_of(&status)); |
|
| 829 | + | head.push(signature_mark_of( |
|
| 830 | + | &status, |
|
| 831 | + | awaits_seal(&repo, &status, &c.author), |
|
| 832 | + | )); |
|
| 830 | 833 | } |
|
| 831 | 834 | println!("{}{}", node_line(row, &bullet), head.join(" ")); |
|
| 832 | 835 | let subject = if e.is_working_copy && c.message.is_empty() { |
|
| … | 2287 unchanged lines |