OXYGENHUB

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