diff --git a/Packages/macOS/CmuxNext/Sources/CmuxNextApp/Cloud/CloudMove.swift b/Packages/macOS/CmuxNext/Sources/CmuxNextApp/Cloud/CloudMove.swift index d2f86da04789..ed14a63c0c56 100644 --- a/Packages/macOS/CmuxNext/Sources/CmuxNextApp/Cloud/CloudMove.swift +++ b/Packages/macOS/CmuxNext/Sources/CmuxNextApp/Cloud/CloudMove.swift @@ -11,7 +11,7 @@ import Foundation /// "Move to Cloud", the tab and chat right-click menus and `cmux cloud move`. /// For a chat tab of this Mac it reuses a Cloud machine (the named one, else a /// connected one, else any, else a new one), copies the chat's folder there -/// (`cmux link sync push`), moves the chat (`cmux link agent move`), and shows +/// (`cmux link seed push`), moves the chat (`cmux link agent move`), and shows /// it on that machine: a Cloud workspace whose terminal runs in the synced /// folder and whose chat tab is bound to the moved session through the /// machine's store, so the tab attaches through that machine and carries its @@ -226,7 +226,7 @@ struct CloudMove { for root in services.cloud.move.workRoots { let remote = folder.remote(under: root) do { - last = try await cli.syncPush(socket: socket, local: folder.root, remote: remote.folder) + last = try await cli.seedPush(socket: socket, local: folder.root, remote: remote.folder) } catch { // Timed out or didn't launch: nothing moved yet, so Retry runs the move again on this machine. throw Failure(message: CloudMoveStrings.syncFailed(machine, CloudStrings.errorText(error)), retry: retry) diff --git a/Packages/macOS/CmuxNext/Sources/CmuxNextApp/Cloud/CloudMoveCLI.swift b/Packages/macOS/CmuxNext/Sources/CmuxNextApp/Cloud/CloudMoveCLI.swift index e02834497bb9..1d14fb6f9ea0 100644 --- a/Packages/macOS/CmuxNext/Sources/CmuxNextApp/Cloud/CloudMoveCLI.swift +++ b/Packages/macOS/CmuxNext/Sources/CmuxNextApp/Cloud/CloudMoveCLI.swift @@ -48,10 +48,10 @@ nonisolated struct CloudMoveCLI: Sendable { /// The error code of an answer that isn't a JSON line (a crash, a kill). static let badAnswer = "bad_answer" - /// `cmux link sync push`: copies the working copy `local` to `remote` on - /// the machine whose daemon `socket` reaches. - func syncPush(socket: String, local: String, remote: String) async throws -> Answer { - try await run(["link", "sync", "push", "--socket", socket, local, remote]) + /// `cmux link seed push`: seeds `local` at `remote` on the machine `socket` reaches from its + /// GitHub remote plus one bundle, else copies it (`link sync push`), saying why. + func seedPush(socket: String, local: String, remote: String) async throws -> Answer { + try await run(["link", "seed", "push", "--socket", socket, local, remote]) } /// `cmux link sync pull`: merges the machine's `remote` (what a push left there) back into @@ -63,7 +63,7 @@ nonisolated struct CloudMoveCLI: Sendable { /// The folder copy a move makes once the chat is frozen (`--sync`): what changed since the /// caller's own sync, so a chat moved mid-turn takes the turn's last changes with it. struct FrozenSync: Sendable, Equatable { - /// Pull from the machine (Bring Back Local) rather than push to it (Move to Cloud). + /// Pull from the machine (Bring Back Local) rather than seed it (Move to Cloud, `--since-last`). var pull: Bool /// The machine link socket `link sync` uses. var socket: String @@ -87,7 +87,7 @@ nonisolated struct CloudMoveCLI: Sendable { /// `agentMove`'s flags after `--cwd`. static func agentMoveFlags(sync: FrozenSync?, askFirst: Bool, vmKind: String?, toPID: Int32?) -> [String] { - let frozen = sync.map { ["--sync", $0.pull ? "pull" : "push", "--sync-socket", $0.socket, "--sync-local", $0.local, "--sync-remote", $0.remote] } ?? [] + let frozen = sync.map { ["--sync", $0.pull ? "pull" : "seed", "--sync-socket", $0.socket, "--sync-local", $0.local, "--sync-remote", $0.remote] } ?? [] return ["--interrupt"] + frozen + (askFirst ? ["--ask-first"] : []) + (vmKind.map { ["--vm-kind", $0] } ?? []) + (toPID.map { ["--to-pid", String($0)] } ?? []) } diff --git a/Packages/macOS/CmuxNext/Sources/CmuxNextApp/Handlers/CloudHandlers+Account.swift b/Packages/macOS/CmuxNext/Sources/CmuxNextApp/Handlers/CloudHandlers+Account.swift index 698af54657a8..0b86eade4983 100644 --- a/Packages/macOS/CmuxNext/Sources/CmuxNextApp/Handlers/CloudHandlers+Account.swift +++ b/Packages/macOS/CmuxNext/Sources/CmuxNextApp/Handlers/CloudHandlers+Account.swift @@ -563,7 +563,7 @@ extension CloudHandlers { do { cwd = try await CloudNewWorkspacePlan.copy(folder, workRoots: cloud.move.workRoots) { remote in try checkScope(session) - let answer = try await cli.syncPush(socket: socket, local: folder.root, remote: remote) + let answer = try await cli.seedPush(socket: socket, local: folder.root, remote: remote) return CloudNewWorkspacePlan.CopyAnswer(ok: answer.ok, errorCode: answer.errorCode, error: answer.error) }.chat } catch is CancellationError { diff --git a/Packages/macOS/CmuxNext/Tests/CmuxNextAppTests/CloudBringBackTests.swift b/Packages/macOS/CmuxNext/Tests/CmuxNextAppTests/CloudBringBackTests.swift index cba749a40878..5f7a2b498eb8 100644 --- a/Packages/macOS/CmuxNext/Tests/CmuxNextAppTests/CloudBringBackTests.swift +++ b/Packages/macOS/CmuxNext/Tests/CmuxNextAppTests/CloudBringBackTests.swift @@ -13,7 +13,7 @@ import Testing /// Bring Back Local (cmuxterm-hq#1874 slice 4) at the app boundary: a chat moved to a Cloud machine /// with `cmux cloud move` comes back with `cmux cloud bring-back`, both sent as the CLI sends them /// to the app's real action registry. This Mac and the machine are two real cmux-tui daemons, as -/// in ``CloudMoveTests``; `cmux link sync push|pull` and `cmux link agent move` are a stand-in +/// in ``CloudMoveTests``; `cmux link seed push, link sync pull` and `cmux link agent move` are a stand-in /// `cmux` that records its arguments and answers as the case asks. The Rust suites cover those /// CLIs against daemons (link_sync.rs, bring_back_journey.rs). @MainActor @Suite(.serialized, .timeLimit(.minutes(3)), .enabled(if: SameTreeDaemonBinary.url != nil, "needs the same-tree cmux-tui")) @@ -300,7 +300,7 @@ struct CloudBringBackTests { let log: URL let vmSocket: String var macSocket = "" - /// The synced folder on the machine (`cmux link sync push`'s target). + /// The synced folder on the machine (`cmux link seed push`'s target). var synced = "" /// The moved chat's tab on the machine, as the app keys it, and its surface there. var machineKey = "" @@ -481,7 +481,7 @@ struct CloudBringBackTests { trip.synced = try #require(trip.calls().first?.last) } - /// A stand-in `cmux` (calls recorded unit-separated): `link sync push` makes the synced folder + /// A stand-in `cmux` (calls recorded unit-separated): `link seed push` makes the synced folder /// and `link agent move` moves the chat to the machine, leaving the landed chat's record as the /// machine daemon's `agent-transfer-finish` would (see ``CloudMoveTests``); then `link sync /// pull` and the move back answer as `mode` says. `link access triage` lists one file ask of the @@ -502,7 +502,7 @@ struct CloudBringBackTests { for arg in "$@"; do line="$line$(printf '\\037')$arg"; done printf '%s\\n' "${line#?}" >> '\(log.path)' case "$1 $2 $3" in - "link sync push") + "link seed push") for target in "$@"; do :; done mkdir -p "$target" echo '{"ok":true,"written":1,"unchanged":0,"deleted":0,"skipped":0,"bytes":4}' ;; diff --git a/Packages/macOS/CmuxNext/Tests/CmuxNextAppTests/CloudMoveTests.swift b/Packages/macOS/CmuxNext/Tests/CmuxNextAppTests/CloudMoveTests.swift index e6ad465d5e72..319ca89447f0 100644 --- a/Packages/macOS/CmuxNext/Tests/CmuxNextAppTests/CloudMoveTests.swift +++ b/Packages/macOS/CmuxNext/Tests/CmuxNextAppTests/CloudMoveTests.swift @@ -14,7 +14,7 @@ import Testing /// chat tab, sent as the CLI sends it (`action.run`, `cli: true`, origin `cli`, a `tab:` target) /// to the app's real action registry. This Mac is a real cmux-tui daemon; the Cloud machine is a /// second real daemon reached the way the app reaches a machine's link socket -/// (`DaemonService.start(remote:)` on a ``CloudMachineSession``). `cmux link sync push` and +/// (`DaemonService.start(remote:)` on a ``CloudMachineSession``). `cmux link seed push` and /// `cmux link agent move` are a stand-in `cmux` that records its arguments and answers their /// JSON lines as the case asks; the Rust suites cover those CLIs against daemons (link_sync.rs, /// link_agent_move.rs, cloud_move_journey.rs). @@ -31,10 +31,10 @@ struct CloudMoveTests { let moved = try await move.read() let synced = move.workRoot.appendingPathComponent(moved.folderName).path - #expect(moved.calls.first == ["link", "sync", "push", "--socket", move.vmSocket, move.project.path, synced]) + #expect(moved.calls.first == ["link", "seed", "push", "--socket", move.vmSocket, move.project.path, synced]) #expect(moved.calls.dropFirst().first == ["link", "agent", "move", "--from-socket", moved.macSocket, "--surface", String(moved.surface), "--to-socket", move.vmSocket, "--cwd", synced, "--interrupt", - "--sync", "push", "--sync-socket", move.vmSocket, "--sync-local", move.project.path, + "--sync", "seed", "--sync-socket", move.vmSocket, "--sync-local", move.project.path, "--sync-remote", synced, "--vm-kind", "none"], "no link said this caller owns the machine: the pump says so, and the machine refuses") #expect(moved.calls.count == 2) @@ -197,7 +197,7 @@ struct CloudMoveTests { while move.calls().count < 2, ContinuousClock.now < deadline { try? await Task.sleep(for: .milliseconds(50)) } let after = await move.toolCards(waiting: false)?.first.flatMap { Self.text($0, "text") } let again = try #require(move.calls().dropFirst().first, "Retry did not run the move again: \(move.calls()); card: \(after ?? "none")") - #expect(Array(again.prefix(5)) == ["link", "sync", "push", "--socket", move.vmSocket], "Retry ran somewhere else: \(again)") + #expect(Array(again.prefix(5)) == ["link", "seed", "push", "--socket", move.vmSocket], "Retry ran somewhere else: \(again)") } } @@ -263,7 +263,7 @@ struct CloudMoveTests { let deadline = ContinuousClock.now + .seconds(origin == .cli ? 30 : 10) while move.calls().count < before + 2, ContinuousClock.now < deadline { try? await Task.sleep(for: .milliseconds(50)) } let ran = move.calls().dropFirst(before).map { Array($0.prefix(3)) } - if origin == .cli { #expect(ran == [["link", "sync", "push"], ["link", "agent", "move"]], "\(ran)") } + if origin == .cli { #expect(ran == [["link", "seed", "push"], ["link", "agent", "move"]], "\(ran)") } // The stop's own bookkeeping, after its last call. try? await Task.sleep(for: .milliseconds(300)) } @@ -280,7 +280,7 @@ struct CloudMoveTests { let moved = try await move.read() let calls = Array(moved.calls.dropFirst(before)) #expect(calls.first == ["link", "machine", "update", "--socket", move.vmSocket], "\(calls)") - #expect(calls.dropFirst().map { Array($0.prefix(3)) } == [["link", "sync", "push"], ["link", "agent", "move"]], "\(calls)") + #expect(calls.dropFirst().map { Array($0.prefix(3)) } == [["link", "seed", "push"], ["link", "agent", "move"]], "\(calls)") #expect(moved.machineTab?.agentSession?.session == "chat-on-vm", "the chat did not move after the update") #expect(!moved.macStillHasTab) } @@ -623,7 +623,7 @@ struct CloudMoveTests { } /// A stand-in `cmux`: records each call's arguments (unit-separated). Like the real sync, - /// `link sync push` creates the synced folder (not in `tabFailed`, so the machine's terminal + /// `link seed push` creates the synced folder (not in `tabFailed`, so the machine's terminal /// can't start there; in `syncTimeout` it outlives the CLI timeout); `link agent move` /// answers as `mode` says. A move that answers ok also leaves what the machine daemon's own /// `agent-transfer-finish` would: the landed chat's record (`agent-transfer-landed.json` next @@ -637,7 +637,7 @@ struct CloudMoveTests { for arg in "$@"; do line="$line$(printf '\\037')$arg"; done printf '%s\\n' "${line#?}" >> '\(log.path)' case "$1 $2 $3" in - "link sync push") + "link seed push") [ '\(mode)' = syncTimeout ] && exec sleep 30 if [ '\(mode)' = syncOutdated ]; then echo '{"ok":false,"error":"the machine runs an older cmux-tui (0000old) without fs-v1; update the machine","error_code":"machine_outdated"}' diff --git a/cmux-tui/crates/cmux-tui/src/link/seed_cli.rs b/cmux-tui/crates/cmux-tui/src/link/seed_cli.rs index 4fc8509eb23b..ef81cad330aa 100644 --- a/cmux-tui/crates/cmux-tui/src/link/seed_cli.rs +++ b/cmux-tui/crates/cmux-tui/src/link/seed_cli.rs @@ -115,6 +115,12 @@ pub(super) fn failure_code(error: &anyhow::Error) -> String { SeedError::of(error).code } +/// The error code and message of a failed [`push`]. +pub(super) fn failure(error: &anyhow::Error) -> (String, String) { + let error = SeedError::of(error); + (error.code, error.message) +} + /// What a push did. #[derive(Debug)] pub(super) struct Pushed { diff --git a/cmux-tui/crates/cmux-tui/src/link/start_chat_cli.rs b/cmux-tui/crates/cmux-tui/src/link/start_chat_cli.rs index f9b978e29091..edd7054a9a64 100644 --- a/cmux-tui/crates/cmux-tui/src/link/start_chat_cli.rs +++ b/cmux-tui/crates/cmux-tui/src/link/start_chat_cli.rs @@ -22,11 +22,14 @@ //! to the machine the App just made for it, and makes it the workspace's //! when the workspace has none yet and no other workspace has it. //! - `copy --key K --account A --machine ID --socket PATH --local ROOT -//! [--subpath SUB] --work-root R...`: copies ROOT to a folder of the send's -//! own on the machine (`cmux link sync push`), trying each work root in -//! turn, unless this send's copy already finished there: a second push -//! would overwrite what the chat changed since. Answers the chat's folder -//! there. +//! [--subpath SUB] --work-root R...`: puts ROOT in a folder of the send's +//! own on the machine (`cmux link seed push`: seeded from its GitHub +//! remote, else copied), trying each work root in turn, unless this send's +//! copy already finished there: a second push would overwrite what the chat +//! changed since. Answers the chat's folder there, and how it got there: +//! `mode` (`seed` or `copy`), `fallback_code`, `fallback_why` and +//! `install_url` when a seed copied instead, and `skipped_files`. The push's +//! progress lines go to stderr. //! - `ensure --workspace W --key K --repo OWNER/NAME [--ref REF] [--environment env_...] //! [--platform URL]`, the cmux credential on stdin: the workspace's own //! machine from the cmux platform (`POST /v1/workspaces/{W}/machine`), made @@ -610,20 +613,33 @@ pub(super) fn import(ledger: &Path, entries: &str) -> Value { } } -/// One push of a folder to a machine: `(socket, local, remote)` to the push's -/// error code and message. -pub(super) type Push<'a> = dyn Fn(&Path, &Path, &str) -> Result<(), (String, String)> + 'a; +/// One push of a folder to a machine: `(socket, local, remote)` to what the +/// answer adds about it ([`pushed_fields`]), or the push's error code and +/// message. +pub(super) type Push<'a> = dyn Fn(&Path, &Path, &str) -> Result + 'a; -/// `cmux link sync push`, after the machine says it serves `fs-v1`. -fn push(socket: &Path, local: &Path, remote: &str) -> Result<(), (String, String)> { - if let Some(outdated) = - super::machine_gate::outdated(socket, &[cmux_tui_core::fs_ops::FS_CAPABILITY]) - { - return Err((super::machine_gate::OUTDATED.to_string(), outdated.message())); +/// `cmux link seed push` (design/cloud.md, "Getting the project onto the +/// machine"): a git folder with a GitHub remote is seeded from it, anything +/// else copied (`cmux link sync push`), which refuses a machine without +/// `fs-v1` (`machine_outdated`) itself. +fn push(socket: &Path, local: &Path, remote: &str) -> Result { + super::seed_cli::push(socket, local, remote) + .map(|pushed| pushed_fields(&pushed.json())) + .map_err(|error| super::seed_cli::failure(&error)) +} + +/// What the copy's answer carries of the push's own line: how it went +/// (`mode`: seed or copy), why a seed copied instead (`fallback_code`, +/// `fallback_why`, and `install_url` when the machine needs the GitHub App), +/// and the files it left on this Mac, by name. +fn pushed_fields(line: &Value) -> Value { + let mut fields = serde_json::Map::new(); + for key in ["mode", "fallback_code", "fallback_why", "install_url", "skipped_files"] { + if let Some(value) = line.get(key).filter(|value| !value.is_null()) { + fields.insert(key.to_string(), value.clone()); + } } - super::sync_cli::push(socket, local, remote) - .map(|_| ()) - .map_err(|error| super::sync_cli::push_failure(&error)) + Value::Object(fields) } /// Folders a chat's copy refuses as too broad: the disk's root and the @@ -782,14 +798,21 @@ impl StartCopy { for root in &self.work_roots { let (folder, chat) = self.remote(root, &place); match push(&self.socket, Path::new(&self.local), &folder) { - Ok(()) => { + Ok(fields) => { let recorded = Ledger::update(ledger, |pins| { pins.record_copy(&chat, &send, &self.machine, &place); }); lease.release(); return match recorded { Ok(()) => { - json!({"ok": true, "chat": chat, "folder": folder, "copied": true}) + let mut answer = + json!({"ok": true, "chat": chat, "folder": folder, "copied": true}); + if let (Some(answer), Value::Object(fields)) = + (answer.as_object_mut(), fields) + { + answer.extend(fields); + } + answer } Err(error) => refused("ledger_failed", format!("{error:#}")), }; diff --git a/cmux-tui/crates/cmux-tui/src/link/start_chat_cli/tests.rs b/cmux-tui/crates/cmux-tui/src/link/start_chat_cli/tests.rs index bbbf0a8054ce..5ff675a1b401 100644 --- a/cmux-tui/crates/cmux-tui/src/link/start_chat_cli/tests.rs +++ b/cmux-tui/crates/cmux-tui/src/link/start_chat_cli/tests.rs @@ -340,7 +340,11 @@ fn a_copy_tries_the_next_work_root_only_when_the_first_is_not_the_machines_home( let (seen, results) = pushes(vec![Err("fs.permission_denied"), Ok(())]); let push = |_: &Path, _: &Path, remote: &str| { seen.borrow_mut().push(remote.to_string()); - results.borrow_mut().remove(0).map_err(|code| (code.to_string(), "refused".to_string())) + results + .borrow_mut() + .remove(0) + .map(|()| json!({})) + .map_err(|code| (code.to_string(), "refused".to_string())) }; let answer = copy.run(&path, &push); assert_eq!(answer["ok"], json!(true)); @@ -358,7 +362,11 @@ fn a_copy_tries_the_next_work_root_only_when_the_first_is_not_the_machines_home( let (seen, results) = pushes(vec![Err("machine_outdated")]); let push = |_: &Path, _: &Path, remote: &str| { seen.borrow_mut().push(remote.to_string()); - results.borrow_mut().remove(0).map_err(|code| (code.to_string(), "old".to_string())) + results + .borrow_mut() + .remove(0) + .map(|()| json!({})) + .map_err(|code| (code.to_string(), "old".to_string())) }; let answer = other.run(&path, &push); assert_eq!(answer["ok"], json!(false)); @@ -375,7 +383,7 @@ fn a_retry_after_a_finished_copy_never_copies_again() { let count = RefCell::new(0); let push = |_: &Path, _: &Path, _: &str| { *count.borrow_mut() += 1; - Ok(()) + Ok(json!({})) }; let first = copy.run(&path, &push); let retry = StartCopy { key: copy.key.to_lowercase(), ..copy.clone() }.run(&path, &push); @@ -533,7 +541,7 @@ fn an_unreadable_ledger_refuses_the_send_and_keeps_its_bytes() { let pushed = RefCell::new(Vec::::new()); let push = |_: &Path, _: &Path, remote: &str| { pushed.borrow_mut().push(remote.to_string()); - Ok(()) + Ok(json!({})) }; assert_eq!(pin(&path, ©.key, ACCOUNT, "vm-a", None)["ok"], json!(true)); assert_eq!(copy.run(&path, &push)["copied"], json!(true)); @@ -584,7 +592,11 @@ fn a_retry_of_a_copy_that_did_not_finish_resumes_its_folder() { let (seen, results) = pushes(vec![Err("timeout"), Ok(())]); let push = |_: &Path, _: &Path, remote: &str| { seen.borrow_mut().push(remote.to_string()); - results.borrow_mut().remove(0).map_err(|code| (code.to_string(), "lost".to_string())) + results + .borrow_mut() + .remove(0) + .map(|()| json!({})) + .map_err(|code| (code.to_string(), "lost".to_string())) }; assert_eq!(copy.run(&path, &push)["error_code"], json!("timeout")); let retry = StartCopy { key: copy.key.to_uppercase(), ..copy.clone() }.run(&path, &push); @@ -597,7 +609,7 @@ fn a_retry_of_a_copy_that_did_not_finish_resumes_its_folder() { let count = RefCell::new(0); let never = |_: &Path, _: &Path, _: &str| { *count.borrow_mut() += 1; - Ok(()) + Ok(json!({})) }; assert_eq!(copy.run(&path, &never)["chat"], retry["chat"]); assert_eq!(*count.borrow(), 0); @@ -613,13 +625,13 @@ fn two_copies_of_one_send_at_once_never_share_a_folder() { let seen = RefCell::new(Vec::::new()); let inner = |_: &Path, _: &Path, remote: &str| { seen.borrow_mut().push(remote.to_string()); - Ok(()) + Ok(json!({})) }; let outer = |_: &Path, _: &Path, remote: &str| { // The second copy starts while the first one's push runs. assert_eq!(copy.run(&path, &inner)["copied"], json!(true)); seen.borrow_mut().push(remote.to_string()); - Ok(()) + Ok(json!({})) }; assert_eq!(copy.run(&path, &outer)["copied"], json!(true)); let seen = seen.borrow(); @@ -650,7 +662,7 @@ fn a_retry_resumes_its_folder_only_when_no_live_copy_holds_it() { drop(held); let ok = |_: &Path, _: &Path, remote: &str| { seen.borrow_mut().push(remote.to_string()); - Ok(()) + Ok(json!({})) }; let done = copy.run(&path, &ok); assert_eq!(done["copied"], json!(true), "{done}"); @@ -691,7 +703,8 @@ fn a_copy_of_the_root_a_system_folder_or_home_is_refused() { } let dir = scratch(); let path = ledger_in(dir.path()); - let never = |_: &Path, _: &Path, _: &str| -> Result<(), (String, String)> { panic!("copied") }; + let never = + |_: &Path, _: &Path, _: &str| -> Result { panic!("copied") }; let answer = copy_of("/System", "").run(&path, &never); assert_eq!(answer["error_code"], json!("folder_too_broad"), "{answer}"); } @@ -736,7 +749,7 @@ fn the_sends_the_app_kept_before_carry_over_once() { ..copy_of("/a", "") }; let never = - |_: &Path, _: &Path, _: &str| -> Result<(), (String, String)> { panic!("copied again") }; + |_: &Path, _: &Path, _: &str| -> Result { panic!("copied again") }; assert_eq!(copy.run(&path, &never)["copied"], json!(false)); // Taking the same list in again changes nothing; one that is not a list is refused. assert_eq!(import(&path, &kept.to_string())["imported"], json!(0)); @@ -1334,3 +1347,26 @@ fn plan_makes_a_personal_repository_sends_machine_through_the_platform() { assert!(!made_no_machine(&E::Status { status: 503, code: "machine.placement_unavailable" })); assert!(!made_no_machine(&E::Status { status: 400, code: "machine.conflict" })); } +/// The copy's answer carries how the push went: a seed, or the copy with why +/// and the GitHub App's install link, and the files left on this Mac. +#[test] +fn a_copy_answers_how_the_push_went() { + let dir = scratch(); + let path = ledger_in(dir.path()); + let copy = copy_of("/Users/me/src/paint", ""); + let push = |_: &Path, _: &Path, _: &str| { + Ok(super::pushed_fields(&json!({ + "ok": true, "mode": "copy", "unchanged": false, "bundle_bytes": 0, + "head": null, "fallback_code": "seed.needs_github", "fallback_why": "private", + "install_url": "https://github.com/apps/cmux/installations/new", + "skipped_files": [{"path": ".env", "kind": "secret", "why": "secret"}], + }))) + }; + let answer = copy.run(&path, &push); + assert_eq!(answer["copied"], json!(true), "{answer}"); + assert_eq!(answer["mode"], json!("copy")); + assert_eq!(answer["fallback_code"], json!("seed.needs_github")); + assert_eq!(answer["install_url"], json!("https://github.com/apps/cmux/installations/new")); + assert_eq!(answer["skipped_files"][0]["path"], json!(".env")); + assert!(answer.get("head").is_none() && answer.get("bundle_bytes").is_none(), "{answer}"); +}