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 |