OXYGENHUB

Commit a resolution by paths, and amend only some paths 69953b507188

Metal Gear had to move other work aside (a workspace file, a pending test
fix) to commit a conflict resolution, which had to be committed whole.

- A resolution may now be committed by paths, leaving other work
  uncommitted on top of the resolved state, so long as the paths include
  every file it resolves, whole; otherwise the refusal names them.
- oxy amend PATH.. folds only those paths into the head (committed as a
  change on it and squashed into it), leaving the rest uncommitted.

Closes #118
Author
Andy McCall @andymccall <[email protected]>
via
agent agent:claude@andymccall <[email protected]> · keyless, under your grant for changes,refs,seal · key f10cad42705c
When
· 2026-10-18 17:03 UTC
Signature
◈ sealed by agent:claude@andymccall
Hash
69953b507188dff068d3b68e03ab3816bb3ee588793b9b07e1ab66c27eeb38a5
Depends on
23b217d75848 2c9ddac3c387 614541c3cebd 6a81dbab471d 86fe29fd62ea 957fee30826e 982864068a59 fce44ae9ad29 ff0af1ee299d
Depended on by
87322662f607
In bookmarks
doctor-trust identical-sides keyless-tags main paths-for-resolutions-and-amend push-notes subrepo-pin-state
In tags
v0.10.0 v0.11.0 v0.5.0 v0.5.1 v0.6.0 v0.7.0 v0.8.0 v0.9.0
Edits
16 atoms, 0 tree operations, 6 files

Browse the tree at this change

modified crates/oxy-core/src/repo/history.rs +28 −0
…
199 unchanged lines
200 200
            .unwrap())
201 201
    }
202 202
203 +
    /// Fold only `paths` of the working copy into the head it sits on
204 +
    /// (oxygen#118): they are committed as a change of their own, on the
205 +
    /// head, which is then squashed into it; the rest stays uncommitted
206 +
    /// work. A new `message` replaces the head's.
207 +
    pub fn amend_paths(&self, message: Option<&str>, paths: &[String]) -> Result<ChangeHash> {
208 +
        let snap = self.snapshot("oxy amend")?;
209 +
        let base_heads = snap.base.maximal(&snap.wc.deps);
210 +
        let [head] = base_heads.as_slice() else {
211 +
            return fail(format!(
212 +
                "the working copy sits on {} heads; check out the single change to amend",
213 +
                base_heads.len()
214 +
            ));
215 +
        };
216 +
        let head = *head;
217 +
        self.rewritable_head(&head)?;
218 +
        drop(snap);
219 +
        let (part, _) = self.commit_selection_partial(
220 +
            "oxy amend: the paths to fold in",
221 +
            &[head],
222 +
            &Selection::Paths(paths.to_vec()),
223 +
        )?;
224 +
        let folded = self.squash(&part, &head)?;
225 +
        match message {
226 +
            Some(m) => self.describe(&folded, m),
227 +
            None => Ok(folded),
228 +
        }
229 +
    }
230 +
203 231
    /// Fold the working copy into the head it sits on: the head is rebuilt
204 232
    /// as the difference between the state beneath it and the files on disk.
205 233
    pub fn amend(&self, message: Option<&str>) -> Result<ChangeHash> {
…
1006 unchanged lines
modified crates/oxy-core/src/repo/snapshot.rs +23 −1
…
867 unchanged lines
868 868
        if tree_ops.is_empty() && edits.is_empty() {
869 869
            return fail("nothing selected to commit");
870 870
        }
871 +
        // A resolution may leave other work uncommitted (oxygen#118), but
872 +
        // takes every file it resolves, whole.
871 873
        if partial && snap.resolves {
872 -
            return fail("a conflict resolution must be committed whole");
874 +
            let resolved = snap
875 +
                .statuses
876 +
                .iter()
877 +
                .filter(|s| s.status == ChangeStatus::Resolved)
878 +
                .map(|s| s.path.as_str());
879 +
            let missing: Vec<&str> = match selection {
880 +
                Selection::Paths(paths) => resolved
881 +
                    .filter(|p| {
882 +
                        !paths.iter().any(|x| {
883 +
                            p == x || p.starts_with(&format!("{}/", x.trim_end_matches('/')))
884 +
                        })
885 +
                    })
886 +
                    .collect(),
887 +
                _ => resolved.collect(),
888 +
            };
889 +
            if !missing.is_empty() {
890 +
                return fail(format!(
891 +
                    "a conflict resolution takes every file it resolves, whole: commit by paths, including {}",
892 +
                    missing.join(", ")
893 +
                ));
894 +
            }
873 895
        }
874 896
        // pre-commit hook: may veto.
875 897
        self.run_hook(
…
2063 unchanged lines
modified crates/oxy-core/src/repo/tests.rs +70 −0
…
242 unchanged lines
243 243
    );
244 244
}
245 245
246 +
/// oxygen#118 (Metal Gear): a resolution commits by paths, leaving other
247 +
/// work uncommitted, so long as it takes every file it resolves; and
248 +
/// `amend PATHS` folds only those paths into the head.
246 249
#[test]
250 +
fn resolutions_and_amends_take_paths() {
251 +
    hermetic();
252 +
    let (dir, repo, _h1, _h2, _h3) = conflicted_repo();
253 +
    let root = dir.path();
254 +
    repo.checkout(&repo.bookmark_heads().unwrap(), false, "checkout main")
255 +
        .unwrap();
256 +
    // The conflict resolved, and other work beside it.
257 +
    write(root, "f.txt", b"a\nX\nc\n");
258 +
    write(root, "notes.txt", b"pending\n");
259 +
    let s = repo.snapshot("status").unwrap();
260 +
    assert!(s.resolves);
261 +
    let err = repo
262 +
        .commit_selection_partial("resolve", &[], &Selection::Paths(vec!["notes.txt".into()]))
263 +
        .unwrap_err()
264 +
        .to_string();
265 +
    assert!(
266 +
        err.contains("takes every file it resolves") && err.contains("f.txt"),
267 +
        "{err}"
268 +
    );
269 +
    let (h4, partial) = repo
270 +
        .commit_selection_partial("resolve", &[], &Selection::Paths(vec!["f.txt".into()]))
271 +
        .unwrap();
272 +
    assert!(partial);
273 +
    assert_eq!(
274 +
        repo.store().get_change(&h4).unwrap().kind,
275 +
        ChangeKind::Resolution
276 +
    );
277 +
    assert!(!repo
278 +
        .load_state(&[h4])
279 +
        .unwrap()
280 +
        .materialize()
281 +
        .is_conflicted());
282 +
    let s = repo.snapshot("status").unwrap();
283 +
    assert_eq!(
284 +
        statuses(&s),
285 +
        vec![("notes.txt".into(), ChangeStatus::Added)],
286 +
        "the other work waits"
287 +
    );
288 +
289 +
    // amend PATHS: only those paths join the head; it keeps its ChangeId.
290 +
    // On the resolution, so the bookmark has one head to amend.
291 +
    let h5 = repo.commit("notes", &[h4]).unwrap();
292 +
    let id5 = repo.store().get_change(&h5).unwrap().change_id;
293 +
    write(root, "notes.txt", b"pending\nmore\n");
294 +
    write(root, "other.txt", b"later\n");
295 +
    let h6 = repo
296 +
        .amend_paths(Some("notes, amended"), &["notes.txt".into()])
297 +
        .unwrap();
298 +
    let c6 = repo.store().get_change(&h6).unwrap();
299 +
    assert_eq!((c6.change_id, c6.message.as_str()), (id5, "notes, amended"));
300 +
    assert_eq!(repo.bookmark_heads().unwrap(), vec![h6]);
301 +
    let snap6 = repo.load_state(&[h6]).unwrap().materialize();
302 +
    assert!(
303 +
        matches!(&snap6.files["notes.txt"].content, FileContent::Text { bytes, .. } if bytes == b"pending\nmore\n")
304 +
    );
305 +
    assert!(
306 +
        !snap6.files.contains_key("other.txt"),
307 +
        "other.txt stays out"
308 +
    );
309 +
    let s = repo.snapshot("status").unwrap();
310 +
    assert_eq!(
311 +
        statuses(&s),
312 +
        vec![("other.txt".into(), ChangeStatus::Added)]
313 +
    );
314 +
}
315 +
316 +
#[test]
247 317
fn resolution_may_keep_both_sides_or_neither() {
248 318
    hermetic();
249 319
    let (dir, repo, _h1, _h2, _h3) = conflicted_repo();
…
6901 unchanged lines
modified docs/spec.md +2 −1
…
493 unchanged lines
494 494
- `oxy commit` (`-m MSG`, or `-F FILE` with `-` for standard input) sets the message, sets `kind = normal`, and starts a fresh working-copy change on top of it. The change is signed immediately if `core.sign = "change"`; with the default `core.sign = "seal"` it is left unsigned locally and covered by a seal at push time (§14.4).
495 495
- `oxy new`, `oxy edit <change>`, and `oxy abandon` move which change the workspace edits.
496 496
- There is no staging area and no stash. A partial commit is a split of the working copy: `oxy commit PATH..` records only the named paths (and every edit of theirs) and leaves the rest as uncommitted work; `oxy commit --interactive` offers each hunk (one `Insert` or `Delete` edit on one file, or one tree operation) for inclusion, like `git add -p`; `oxy split` does the same to an existing change. A hunk that another selected hunk refers to is selected with it, so the committed change is always well-formed (§8.3).
497 +
- A **resolution** (a working copy that resolves its base's conflicts, §9.6) may be committed by paths too, so other work need not be put aside (oxygen#118): the named paths must include every file it resolves, whole, or the commit is refused naming them; what is left stays uncommitted on top of the resolved state. `oxy amend PATH..` folds only those paths into the head (committed as a change on it, then squashed into it, §8.5), leaving the rest uncommitted; `-m` replaces the head's message as usual.
497 498
498 499
### 12.4 Snapshot performance
499 500
…
375 unchanged lines
875 876
oxy status [--json]                 oxy diff                     oxy prompt [--format F]
876 877
oxy commit -m MSG [--depends-on CHANGE]..
877 878
oxy log [-r REVSET] [-n N] [-v] [-L S,E:PATH]   oxy show CHANGE | -r REVSET
878 -
oxy describe [CHANGE] [-m MSG]      oxy amend [-m MSG] [--into CHANGE]
879 +
oxy describe [CHANGE] [-m MSG]      oxy amend [-m MSG] [--into CHANGE | PATH..]
879 880
oxy split CHANGE [PATH]..           oxy abandon CHANGE | -r REVSET [--yes]   oxy resolve
880 881
oxy checkout [CHANGE].. [--discard|--carry] oxy undo                     oxy op log
881 882
oxy tag create NAME [CHANGE].. [-m MSG]   oxy tag list
…
164 unchanged lines
modified src/cli/mod.rs +8 −2
…
234 unchanged lines
235 235
        #[arg(short, long)]
236 236
        message: Option<String>,
237 237
        /// Amend an older change; the edits must touch only lines it already knows
238 -
        #[arg(long)]
238 +
        #[arg(long, conflicts_with = "paths")]
239 239
        into: Option<String>,
240 +
        /// Fold in only these paths; the rest stays as uncommitted work
241 +
        paths: Vec<String>,
240 242
    },
241 243
    /// Split a head: the named paths become a separate first change
242 244
    Split {
…
862 unchanged lines
1105 1107
            }
1106 1108
            None => workspace::describe_name(json),
1107 1109
        },
1108 -
        Cmd::Amend { message, into } => workspace::amend(message.as_deref(), into.as_deref()),
1110 +
        Cmd::Amend {
1111 +
            message,
1112 +
            into,
1113 +
            paths,
1114 +
        } => workspace::amend(message.as_deref(), into.as_deref(), &paths),
1109 1115
        Cmd::Split { change, paths } => workspace::split(&change, &paths),
1110 1116
        Cmd::Abandon {
1111 1117
            change,
…
244 unchanged lines
modified src/cli/workspace.rs +2 −1
…
1769 unchanged lines
1770 1770
    Ok(0)
1771 1771
}
1772 1772
1773 -
pub fn amend(message: Option<&str>, into: Option<&str>) -> Result<i32> {
1773 +
pub fn amend(message: Option<&str>, into: Option<&str>, paths: &[String]) -> Result<i32> {
1774 1774
    let repo = open()?;
1775 1775
    let new = match into {
1776 1776
        Some(spec) => {
1777 1777
            let target = repo.resolve_change(spec)?;
1778 1778
            repo.amend_into(&target, message)?
1779 1779
        }
1780 +
        None if !paths.is_empty() => repo.amend_paths(message, paths)?,
1780 1781
        None => repo.amend(message)?,
1781 1782
    };
1782 1783
    let c = repo.store().get_change_meta(&new)?;
…
753 unchanged lines