diff --git a/.chainlink/issues.db b/.chainlink/issues.db index bb1293e788dd09966abca7f890a8a0f2c0a3d0b4..1f88dd895d6c6f9a16ec7253554a770b1be689b1 100644 GIT binary patch delta 1084 zcmZozz|pXPV}dlJ`9v9KM)QpcOZfR%d080vWBHErS@JIAW!Wq!@QJ6sQHqtDp;4Ma zRFuKjm%%Y5MIkk>Brz{NCsiRcFSA4;xFoee!O*};Avr%UEi=8eC{>}Lv^YbdC^a{~ zB(+!}F)u}-peQr1L?JCRFEK}(<(8|bE z&(gxc*xXp#z`)ADz{sE;zk)_l7H)<{VFpoQkn2JVQW8s271Rrg@{?0jQxp>OD$5fq z)fMvcOHvg|@)Zgai%S$rQuB&a!OqIfFH2R(EKw-PFD}sq`9s&x0OYcq{BpR%%)u@* zFtIW<(=#_VG&C`QxeVE1nDUz~<<<)7uy8RkOEP8_7ni0MvobI+@NyQX78hsc=N0oZ zFfg$4A7bGD!vCEA7XNwvLz@K^HtX(gy>+|e8XR70Us;>6$btv{O|dn z@!#dYvRP2!5Wf%yvnV6TqpUCn%jReQ z3y`|+{Ga&W^1s+D7;ueWl7*RpkrhO+fCy#~!NknWz{$0lG2p-aq6C3O2?CoESOj=j zcq|zB?(?qXN#?QGEGVGCQ?JRw!Qjil$iTn=k0NjkLDP=8iMhE2B!*a?4 z>(LcMqsz#|(%2HB7}YQXGb=+wJqu$?3nN2_Jc}nN4i%K^O%#&za|?1(OH!?Hq_a|R zB1}O`ZyM_5iNy-psRbqK`sx{pWvL2Dsi}Dii76?mDeC&_B^jB;3i(Bu>6v-znmP)g zlnF5bDQSYzX?GtoYQ5%;J*Fy!80A)RN>3g|ft=%*3ReR6T{F)PkJE z*08Q#NV*mgE delta 245 zcmZozz|pXPV}dlJ-b5K^M!k&*OZd5%`79auWBHErS#B0o(B|80DYsTogNciYS&}ic zxVSX6n3;irftRy5wYWGlKd+dVfq{XU{}u!P7yjq`w>Ap~oZ+8*%U+9%ng0ez>>2-! z&4K}^_&4A1*IB^D%>Rsm{|Enj{%4y718(qde)eBZfPlY zDhO~f^M7FA|HJ={|HEd%fJgk>KiD&h7;rK3e*%eq=l`@>FyIM4KMOMhBQuC#VrFLG qR~a3;9*qUNP{qs59_e^R@Aw;rYrV#U0Cah)adDguRtb zo8vTV9?Mx4_05U`j4bty%KY36jk*k?q71&i3_+=BiOD7TMOF&snMJ7z&iQ$1ndw$m zIr)hx`o)Q5sS1h33I#>^DW%CJnfZCT1&JjY3XTDuItodt>6v*7!6m5$3WmlC8Hq(H zsd<@s>G=hzc^R4M8Ab+1X1WHZx<B<7{(q$(7H-II}-SE5i~ zl3Gz>rI47DqmWdTn3tT9TC87^m|m=-kd|3gT%uc?T2fk|kXM?Lo|>GKU!0nflV6VN zaRXB;12a7%3kwTFQ*5p?G_^9Z&@(f&u&}@)pAPmunu10fZf=G~aRy0Akn`O#D-;S! zi!&5LgM1Y7i&9dHtQ2xHi;D7#6g2Yk^K?^E(-KQ_N;DxZ)=|hW%1qD9Q_z5m=Oh-F zO#Ub(UTIQWBFLB;}FA!qUXV z3^V2sVTF)qU|{G0MT{sYVi+iin0j!Gfno;a*WA>U%+g%skVe>!J*Ld9OdHjBxEKl< zBtfNsZ#XFL7p3Orm!v9`CFW$NB!Y4~G_RR!DrDxB8Gj~!B7X?K+h#!p1%68&er85l{vv4M#?8mf zD9x9anpjd=l*-1-%qYlPTEWf3%qYzR6=dgTW)$X5DovkUZ{MoK&u;;;HpQjG8+n7jxkHwUvQBPc=eBgD8E7#P^|3sMmxJe%kJPZLn$ z=PzX7|H1#B{~7;X{ww^a`Mde6_zU@)`48}K880-Ybm;A3YN zX3WVi=Y-bk}7F1~Bmt$pSWaQ@uk$ik0l9v}m^6-F2Ztm^J>>2G0l=%6#feic3|B3%C{|o-d z{P*~8@?YUU&wq;lDE|Te-Td1&3o1O-BomXSz;s0)CSg!*0;;LZ^h^xR4Gc{n>6yRDg`bnb z*O#${k%0j@lUZ098G))60~0et3#^IX$QUGVW@c$_VE{3V&ly||DCC!xKR6g;b0zI`jz9c`s zq&z=9CqFqcCmy7;B(+#UBds(ir&0kdsH2dVuMiyUp^%rFnv$BLsiTmXk^(XvRE5Qt zXO?8dm*%Bb6r?7Xq^88zCl;lbf=bx<)QZfK;`qG$ysFfqd{FLBO)r8J2_>n;C3*@* zrWOhyREgkpje>DGD0-c{!PRsUUkbbrcK~(h@UsN{dp9^_pe)zyaB6 zh#rujve!tj-qOU((h^HGXJKVzU}a*eXKZ3-X>JG&Nl#dPkeY%hnjm#Eyp5m-2`{~( zVk?F8%nIG4#NyNxg@U3~T~G+36m@y|CH1FCYLDWm!%ddq}OLwD5T{VDdd*slw^WkSXz{$r(mK`nwMDu4R37) z6O8yMR?sj8M}&@oAv_kq@vobdSdB)=pv2Wl!L7VFdX znnHNNQPiS{9z_O_hKi+`xe?YFGBC3;w$w8;GcvY>Hp6&B3sMqGV5v<3++ZrnS5OBB zJt&Z1O(q3UyRIZLy}nqXBwryZza#?`4C$%G>I#{85PnHUszO12afxm+xPc1Rk(yYP zQ>hC|stTZHX|bMyTV_RSib6?wzCuxHaY;&kGB{u}^HNgtN>WpFb21?Ylw{_WLQ->i zViBm(S`4x$Ilms%_SGvYR?v9VHc25lKM$_37@C0;jC6|&5|dLEAZCJ6M~OmNW^rj^ zjzVHiW_lhd_khCJ#1bWB)Ad@7dB6eN8IKyU=2oVV8q~zZyxt6Z$eMtfh{hIXW(LrZ z9c?D98mVgpkUVJ7fdxFw%Zen(7d~$9I zC^=>3m7o-w#nuXG@Z_1ESprID$@w`si3P=(Nja&ynQ033$vK$?1(gc$R9OltS`~6q zOB9kb5;OCP6*P(=@mHLgmz=6#sApk;EwO=$0Z5G@FG6pB+zV4<3pT9TX*Us{v{ZU%rG5g;D89RUr6 zlKgs56$MJ1;M`nLS_I1<#R^5K;EV>+6Q7v}GqpGsJTw5#X_=UJY=RfNDa`c4JOZRBR9QVOq}& E00DnR%m4rY delta 378 zcmZozz}c{XbAmKu!bBNo#)ORt3;CrP7#O%X?lACM^R@Aw;rYrV#U0Cah)adDgyYU; zL4^Yxe2t=9+zgGp45E?@zP=2TCyP5wo+KeU`IWfB49w{+awe{Pmj!6_WWU*W0&BaPgOc zR6OIq!GDT>5C1y;1^kovTlmX13o1nMZ!YuiUI2C`|5OJ4Z~U+MAMju0KgGYFe+&Og z{(1aUHw!9M@^f-9i!$aHq~>vLp7%dZK!S^ZJ_G*`{`dUP`0w&x;XloPfPX9hD*pMK z1r?h4x6hYnOa-Yq#=!rF{~P}Y{#X1@`0w%G;J?IwhX2@RL4{TP+mG2Z+8Ic2@t+5& z`Og1||1JLu{>S|H_;2!G;Xl7wP+>Fw_Ve|OrUy8fc;p#)ym;g{3n~cnaA, alias: Option, @@ -75,7 +80,14 @@ pub fn run( ) -> Result<(), Box> { let path = config_path()?; let work_dir = std::env::current_dir()?; - run_with_paths(repo, alias, &path, &work_dir, quiet, debug) + let skip_check = std::env::var("ENTANGLE_SKIP_REMOTE_CHECK").is_ok(); + run_with_paths(repo, alias, &path, &work_dir, quiet, debug, |origin, mirror| { + if skip_check { + return Ok(()); + } + remote::validate_remotes(origin, mirror) + .map_err(|e| -> Box { Box::new(e) }) + }) } // --------------------------------------------------------------------------- @@ -90,6 +102,11 @@ pub fn run( /// `quiet` and `debug` correspond to the CLI flags; they override the /// `verbosity_preference` field in the loaded config. Pass both as `false` /// to use the config-file preference (which defaults to [`VerbosityLevel::Verbose`]). +/// +/// `remote_validator` is called with `(origin_url, mirror_url)` to verify +/// that both remotes are reachable before touching `.git/config`. In +/// production this is [`remote::validate_remotes`] (which does real SSH +/// ls-refs). In unit tests pass `|_, _| Ok(())` to skip network I/O. pub fn run_with_paths( repo: Option, alias: Option, @@ -97,6 +114,7 @@ pub fn run_with_paths( work_dir: &Path, quiet: bool, debug: bool, + remote_validator: impl Fn(&str, &str) -> Result<(), Box>, ) -> Result<(), Box> { // ── 1. Load config ─────────────────────────────────────────────────────── let config = match Config::load_from_path(config_path) { @@ -170,84 +188,186 @@ pub fn run_with_paths( vlog!(verbosity, Verbose, " Origin (fetch + push): {origin_url}"); vlog!(verbosity, Verbose, " Mirror (push only): {mirror_url}"); - // ── 7. Inspect existing remotes; handle overwrite prompt ───────────────── - // - // We read the current `origin` state AFTER showing the URL preview so the - // user can see the intended configuration before being asked about conflicts. + // ── 7. Inspect existing remotes ────────────────────────────────────────── use crate::git::OriginStatus; let origin_status = git::get_origin_status(work_dir)?; vlog!(verbosity, Debug, " [debug] origin status: {:?}", origin_status); + // ── 7a. Early exit if already fully configured ──────────────────────────── + // + // Both push URLs present → a previous `entangle init` (or manual setup) + // already wired the dual-push configuration. Exit cleanly before hitting + // the network — no point validating if nothing needs to change. + if let OriginStatus::Present { push_urls, .. } = &origin_status { + let has_origin_push = push_urls.iter().any(|u| u == &origin_url); + let has_mirror_push = push_urls.iter().any(|u| u == &mirror_url); + + vlog!(verbosity, Debug, + " [debug] push_urls={push_urls:?} has_origin_push={has_origin_push} has_mirror_push={has_mirror_push}" + ); + + if has_origin_push && has_mirror_push { + vlog!(verbosity, Verbose, ""); + vlog!(verbosity, Verbose, "✓ Both push remotes are already configured. Nothing to do."); + vlog!(verbosity, Verbose, " Run `entangle shove` to push all branches and tags to both forges."); + return Ok(()); + } + } + + // ── 7b. Validate remote accessibility ───────────────────────────────────── + // + // Verify both SSH endpoints are reachable (or the user accepts the offline + // override) before touching `.git/config`. Placed after URL preview so the + // user sees the intended targets before we go to the network, and after the + // early-exit check so we don't hit the network for already-configured repos. + // + // Three outcomes from the validator: + // • Ok(()) → both reachable (or user accepted offline override). + // • NotFound/Auth → hard stop; user must fix the URL or SSH key first. + // • NetworkError declined → OfflineAborted; user cancelled at the prompt. + vlog!(verbosity, Verbose, ""); + vlog!(verbosity, Verbose, "Checking remote accessibility…"); + if let Err(e) = remote_validator(&origin_url, &mirror_url) { + eprintln!("Error: {e}"); + return Err(e); + } + vlog!(verbosity, Verbose, "✓ Both remotes are accessible."); + + // ── 7c. Handle overwrite prompt if fetch URL doesn't match ─────────────── + // + // Two variables capture the decisions made here so Step 10 and the + // post-action note can use them: + // + // `replace_fetch_url` — true if the user chose to replace the existing + // origin fetch URL. Step 10 swaps it before + // adding push URLs. + // + // `kept_existing_fetch` — Some(url) if the user chose to proceed without + // replacing. Step 10 skips touching the fetch URL; + // a ⚠ note is shown after Step 10 completes. + let mut replace_fetch_url = false; + let mut kept_existing_fetch: Option = None; + match &origin_status { OriginStatus::Absent => { // No existing `origin` remote — Step 10 will create one from scratch. vlog!(verbosity, Debug, " [debug] no origin remote found; will create from scratch"); } - OriginStatus::Present { fetch_url, push_urls } => { - // ── 7a. Early exit if already fully configured ──────────────────── - // - // Both push URLs being present means a previous `entangle init` (or - // manual setup) has already wired up the dual-push configuration. - // Nothing left to do — exit cleanly rather than re-adding duplicates. - let has_origin_push = push_urls.iter().any(|u| u == &origin_url); - let has_mirror_push = push_urls.iter().any(|u| u == &mirror_url); - - vlog!(verbosity, Debug, - " [debug] push_urls={push_urls:?} has_origin_push={has_origin_push} has_mirror_push={has_mirror_push}" - ); - - if has_origin_push && has_mirror_push { - vlog!(verbosity, Verbose, ""); - vlog!(verbosity, Verbose, "✓ Both push remotes are already configured. Nothing to do."); - vlog!(verbosity, Verbose, " Run `entangle shove` to push to both forges."); - return Ok(()); - } - - // ── 7b. Overwrite prompt if fetch URL doesn't match ─────────────── - // - // If the existing `origin` fetch URL differs from the one we'd set - // (e.g., a GitLab URL from a previous project), we must ask before - // touching it. Two outcomes: - // • Replace → Step 10 will swap the fetch URL and add push URLs. - // • Proceed → Step 10 will only add push URLs, leaving fetch alone. - // • Abort → exit cleanly, no changes. + OriginStatus::Present { fetch_url, push_urls: _ } => { if fetch_url != &origin_url { + // Fetch URL mismatch (e.g., GitLab remote, a fork, different user). + // Three outcomes: replace, proceed-as-is, or abort. vlog!(verbosity, Debug, " [debug] fetch URL mismatch: existing={fetch_url} expected={origin_url}" ); let replace = prompt_replace_origin(fetch_url, &origin_url)?; - if !replace { + if replace { + replace_fetch_url = true; + } else { let proceed = prompt_proceed_anyway(fetch_url)?; if !proceed { - // "cancelled" is always printed — it's the user's confirmation - // that the abort happened, not just an informational tip. + // Always printed — the user's confirmation that the abort + // happened, not a suppressible informational tip. println!("Init cancelled. No changes were made."); return Ok(()); } - // Proceeding without replacing: warn about the resulting state. - // Step 10 will add push URLs but leave the fetch URL as-is. - vlog!(verbosity, Verbose, ""); - vlog!(verbosity, Verbose, - "⚠ Note: origin fetch URL ({fetch_url}) will be kept as-is." - ); - vlog!(verbosity, Verbose, - " Push URLs will be added — pushes will reach both forges," - ); - vlog!(verbosity, Verbose, " but fetches will come from the existing origin."); + kept_existing_fetch = Some(fetch_url.clone()); } - // If replace == true, Step 10 will replace the origin and add push URLs. } - // If fetch_url == origin_url, Step 10 will add missing push URLs silently. + // If fetch_url == origin_url, Step 10 adds missing push URLs silently. } } - // Step 10 will continue from here: add push URLs to origin (replacing the - // fetch URL first if `replace` was chosen above). + vlog!(verbosity, Debug, + " [debug] replace_fetch_url={replace_fetch_url} kept_existing_fetch={kept_existing_fetch:?}" + ); + + // ── Step 10: Configure remotes ─────────────────────────────────────────── + // + // Three paths depending on what Step 9 found and decided: + // + // Absent → create origin from scratch with both push URLs. + // Present, replaced → replace fetch URL, then add both push URLs. + // Present, kept/match → leave fetch URL alone, add whichever push URLs + // are missing (the caller already checked that at + // least one is absent — the "both present" path + // returned early in Step 9). + match &origin_status { + OriginStatus::Absent => { + // Non-default (mirror) forge first, default (origin) forge last. + // This matches the convention in the Tangled docs and in DESIGN.md + // steps 9–10: the origin URL is "re-added" as a push URL after the + // mirror, so it appears last in the config. + git::create_origin_remote( + work_dir, + &origin_url, + &[mirror_url.as_str(), origin_url.as_str()], + )?; + vlog!(verbosity, Debug, " [debug] created origin remote with fetch + 2 push URLs"); + } + + OriginStatus::Present { fetch_url: _, push_urls } => { + if replace_fetch_url { + git::set_origin_fetch_url(work_dir, &origin_url)?; + vlog!(verbosity, Debug, " [debug] replaced origin fetch URL → {origin_url}"); + } + + // Add whichever push URLs are not yet present, preserving the + // non-default-first, default-last ordering convention. + let mut to_add: Vec<&str> = Vec::new(); + if !push_urls.iter().any(|u| u == &mirror_url) { + to_add.push(mirror_url.as_str()); + } + if !push_urls.iter().any(|u| u == &origin_url) { + to_add.push(origin_url.as_str()); + } + vlog!(verbosity, Debug, " [debug] push URLs to add: {:?}", to_add); + if !to_add.is_empty() { + git::add_push_urls_to_origin(work_dir, &to_add)?; + } + } + } + + // ── Print final remote state ───────────────────────────────────────────── + // + // Show a `git remote -v`-style summary of what origin looks like after the + // changes. The fetch URL is `origin_url` unless the user chose to keep an + // existing URL (`kept_existing_fetch`). + let final_fetch_url = match &kept_existing_fetch { + Some(url) => url.as_str(), + None => origin_url.as_str(), + }; + + vlog!(verbosity, Verbose, ""); + // Display mirrors the actual config order: mirror (non-default) first, + // origin (default) last — matching `git remote -v` output conventions. + vlog!(verbosity, Verbose, "✓ Remotes configured for '{repo_name}':"); + vlog!(verbosity, Verbose, ""); + vlog!(verbosity, Verbose, " origin {final_fetch_url} (fetch)"); + vlog!(verbosity, Verbose, " origin {mirror_url} (push)"); + vlog!(verbosity, Verbose, " origin {origin_url} (push)"); + vlog!(verbosity, Verbose, ""); + vlog!(verbosity, Verbose, "Run `entangle shove` to push all branches and tags to both forges."); + + // ── Post-action note for the "kept existing fetch URL" path ────────────── + // + // Placed here — after Step 10 — so "was kept" and "have been added" are + // factually accurate at the point the user reads them. + if let Some(ref existing_url) = kept_existing_fetch { + vlog!(verbosity, Verbose, ""); + vlog!(verbosity, Verbose, + "⚠ Note: origin fetch URL ({existing_url}) was kept as-is." + ); + vlog!(verbosity, Verbose, + " Push URLs have been added — pushes will reach both forges," + ); + vlog!(verbosity, Verbose, " but fetches will come from this origin."); + } Ok(()) } @@ -420,6 +540,15 @@ mod tests { // ── Helpers ────────────────────────────────────────────────────────────── + /// No-op remote validator for unit tests — skips all network I/O. + /// + /// Pass this wherever `run_with_paths` requires a `remote_validator`. + /// Integration tests that spawn the binary use `ENTANGLE_SKIP_REMOTE_CHECK` + /// instead; this function is only for in-process unit tests. + fn skip_validate(_: &str, _: &str) -> Result<(), Box> { + Ok(()) + } + fn write_valid_config(path: &Path) { let cfg = Config { github_username: "cyrusae".to_string(), @@ -455,6 +584,7 @@ mod tests { &work_dir, false, false, + skip_validate, ); assert!(result.is_err(), "must error when config is missing"); } @@ -466,7 +596,7 @@ mod tests { let (_dir, config_path, work_dir) = fresh_dirs(); assert!(!git::is_git_repo(&work_dir), "precondition: not yet a git repo"); - run_with_paths(Some("entangle".to_string()), None, &config_path, &work_dir, false, false).unwrap(); + run_with_paths(Some("entangle".to_string()), None, &config_path, &work_dir, false, false, skip_validate).unwrap(); assert!( git::is_git_repo(&work_dir), @@ -479,9 +609,9 @@ mod tests { let (_dir, config_path, work_dir) = fresh_dirs(); // First run — initializes. - run_with_paths(Some("entangle".to_string()), None, &config_path, &work_dir, false, false).unwrap(); + run_with_paths(Some("entangle".to_string()), None, &config_path, &work_dir, false, false, skip_validate).unwrap(); // Second run — must not error. - run_with_paths(Some("entangle".to_string()), None, &config_path, &work_dir, false, false).unwrap(); + run_with_paths(Some("entangle".to_string()), None, &config_path, &work_dir, false, false, skip_validate).unwrap(); assert!(git::is_git_repo(&work_dir)); } @@ -499,6 +629,7 @@ mod tests { &work_dir, false, false, + skip_validate, ); assert!(result.is_err(), "invalid repo name must cause an error"); // No git repo should have been created. @@ -519,6 +650,7 @@ mod tests { &work_dir, false, false, + skip_validate, ); assert!(result.is_err(), "invalid alias must cause an error"); } @@ -534,6 +666,7 @@ mod tests { &work_dir, false, false, + skip_validate, ) .unwrap(); } @@ -600,7 +733,7 @@ mod tests { fn run_with_no_origin_proceeds_to_url_preview() { // Fresh repo, no remotes — must print the URL preview and return Ok. let (_dir, config_path, work_dir) = fresh_dirs(); - run_with_paths(Some("entangle".to_string()), None, &config_path, &work_dir, false, false) + run_with_paths(Some("entangle".to_string()), None, &config_path, &work_dir, false, false, skip_validate) .expect("must succeed when no origin remote is configured"); // The test passes if run_with_paths does not error. Output is checked // in integration tests. @@ -611,7 +744,7 @@ mod tests { // Origin fetch URL already matches what we'd set — no prompt, proceed. let (_dir, config_path, work_dir) = fresh_dirs(); // First run initializes the git repo. - run_with_paths(Some("entangle".to_string()), None, &config_path, &work_dir, false, false).unwrap(); + run_with_paths(Some("entangle".to_string()), None, &config_path, &work_dir, false, false, skip_validate).unwrap(); // Set up an origin with a matching URL (what a github-preference config gives). append_origin( &work_dir, @@ -619,7 +752,7 @@ mod tests { &[], ); // Second run sees matching origin — must not error, no prompt. - run_with_paths(Some("entangle".to_string()), None, &config_path, &work_dir, false, false) + run_with_paths(Some("entangle".to_string()), None, &config_path, &work_dir, false, false, skip_validate) .expect("must succeed when origin fetch URL matches expected URL"); } @@ -627,7 +760,7 @@ mod tests { fn run_exits_early_when_both_push_urls_already_configured() { // Both push URLs present → early exit with success, no changes needed. let (_dir, config_path, work_dir) = fresh_dirs(); - run_with_paths(Some("entangle".to_string()), None, &config_path, &work_dir, false, false).unwrap(); + run_with_paths(Some("entangle".to_string()), None, &config_path, &work_dir, false, false, skip_validate).unwrap(); // Add origin with BOTH push URLs already set. append_origin( @@ -640,7 +773,7 @@ mod tests { ); // Should return Ok (early exit, not an error). - run_with_paths(Some("entangle".to_string()), None, &config_path, &work_dir, false, false) + run_with_paths(Some("entangle".to_string()), None, &config_path, &work_dir, false, false, skip_validate) .expect("must succeed (early exit) when both push URLs are already configured"); } @@ -649,7 +782,7 @@ mod tests { // Only one push URL present → must proceed (not early-exit) so Step 10 // can add the missing one. let (_dir, config_path, work_dir) = fresh_dirs(); - run_with_paths(Some("entangle".to_string()), None, &config_path, &work_dir, false, false).unwrap(); + run_with_paths(Some("entangle".to_string()), None, &config_path, &work_dir, false, false, skip_validate).unwrap(); // Add origin with only the GitHub push URL (Tangled missing). append_origin( @@ -658,7 +791,136 @@ mod tests { &["git@github.com:cyrusae/entangle.git"], ); - run_with_paths(Some("entangle".to_string()), None, &config_path, &work_dir, false, false) + run_with_paths(Some("entangle".to_string()), None, &config_path, &work_dir, false, false, skip_validate) .expect("must succeed when only one push URL is configured"); } + + // ── Step 10: Verify configured remote state ─────────────────────────────── + // + // These tests use `git::get_origin_status` to read back the actual `.git/config` + // state after `run_with_paths` completes, confirming that Step 10 wrote the + // correct entries. + + #[test] + fn run_configures_origin_remote_in_fresh_repo() { + // After the very first run on a fresh directory, origin must have the + // expected fetch URL and both push URLs in the correct order: + // mirror (Tangled) first, origin (GitHub) last — matching the Tangled + // docs convention and DESIGN.md steps 9–10. + let (_dir, config_path, work_dir) = fresh_dirs(); + run_with_paths(Some("entangle".to_string()), None, &config_path, &work_dir, false, false, skip_validate) + .unwrap(); + + let status = git::get_origin_status(&work_dir).unwrap(); + match status { + git::OriginStatus::Present { fetch_url, push_urls } => { + assert_eq!(fetch_url, "git@github.com:cyrusae/entangle.git"); + assert_eq!(push_urls.len(), 2, "must configure both push URLs: {push_urls:?}"); + // Order: mirror (Tangled, non-default) first, origin (GitHub, default) last. + assert_eq!(push_urls[0], "git@tangled.org:atdot.fyi/entangle", + "Tangled (mirror) must be the first push URL"); + assert_eq!(push_urls[1], "git@github.com:cyrusae/entangle.git", + "GitHub (origin) must be the second (last) push URL"); + } + git::OriginStatus::Absent => panic!("expected Present after init, got Absent"), + } + } + + #[test] + fn run_adds_both_push_urls_when_origin_has_matching_url_but_none() { + // Origin fetch URL already matches what entangle would set, but no push + // URLs are configured. run_with_paths must add both without a prompt, + // in the correct order (mirror first, origin last). + let (_dir, config_path, work_dir) = fresh_dirs(); + gix::init(&work_dir).unwrap(); + + // Write origin with correct fetch URL but no push URLs. + { + use std::io::Write as _; + let cfg = work_dir.join(".git").join("config"); + let mut f = std::fs::OpenOptions::new().append(true).open(cfg).unwrap(); + writeln!(f, "\n[remote \"origin\"]").unwrap(); + writeln!(f, "\turl = git@github.com:cyrusae/entangle.git").unwrap(); + writeln!(f, "\tfetch = +refs/heads/*:refs/remotes/origin/*").unwrap(); + } + + run_with_paths(Some("entangle".to_string()), None, &config_path, &work_dir, false, false, skip_validate) + .unwrap(); + + let status = git::get_origin_status(&work_dir).unwrap(); + match status { + git::OriginStatus::Present { fetch_url, push_urls } => { + assert_eq!(fetch_url, "git@github.com:cyrusae/entangle.git"); + assert_eq!(push_urls.len(), 2, "both push URLs must be added: {push_urls:?}"); + // Order: mirror (Tangled, non-default) first, origin (GitHub, default) last. + assert_eq!(push_urls[0], "git@tangled.org:atdot.fyi/entangle", + "Tangled (mirror) must be the first push URL"); + assert_eq!(push_urls[1], "git@github.com:cyrusae/entangle.git", + "GitHub (origin) must be the second (last) push URL"); + } + git::OriginStatus::Absent => panic!("expected Present"), + } + } + + #[test] + fn run_is_fully_idempotent_after_step_10() { + // Running twice on the same repo must succeed both times. + // Second run sees both push URLs → early-exits cleanly. + let (_dir, config_path, work_dir) = fresh_dirs(); + run_with_paths(Some("entangle".to_string()), None, &config_path, &work_dir, false, false, skip_validate) + .unwrap(); + run_with_paths(Some("entangle".to_string()), None, &config_path, &work_dir, false, false, skip_validate) + .unwrap(); + + let status = git::get_origin_status(&work_dir).unwrap(); + match status { + git::OriginStatus::Present { push_urls, .. } => { + // Must still have exactly the two push URLs — second run must + // not have duplicated them. + let origin_count = push_urls + .iter() + .filter(|u| u.as_str() == "git@github.com:cyrusae/entangle.git") + .count(); + let mirror_count = push_urls + .iter() + .filter(|u| u.as_str() == "git@tangled.org:atdot.fyi/entangle") + .count(); + assert_eq!(origin_count, 1, "origin push URL must not be duplicated"); + assert_eq!(mirror_count, 1, "mirror push URL must not be duplicated"); + } + git::OriginStatus::Absent => panic!("expected Present"), + } + } + + #[test] + fn run_with_alias_uses_alias_for_tangled_push_url() { + // When an alias is supplied, the Tangled push URL must use the alias + // instead of the primary repo name. + let (_dir, config_path, work_dir) = fresh_dirs(); + run_with_paths( + Some("my-repo".to_string()), + Some("mirror-alias".to_string()), + &config_path, + &work_dir, + false, + false, + skip_validate, + ) + .unwrap(); + + let status = git::get_origin_status(&work_dir).unwrap(); + match status { + git::OriginStatus::Present { push_urls, .. } => { + assert!( + push_urls.iter().any(|u| u.contains("mirror-alias")), + "Tangled push URL must use the alias: {push_urls:?}" + ); + assert!( + push_urls.iter().any(|u| u.contains("my-repo")), + "GitHub push URL must use the primary repo name: {push_urls:?}" + ); + } + git::OriginStatus::Absent => panic!("expected Present"), + } + } } diff --git a/entangle/src/commands/shove.rs b/entangle/src/commands/shove.rs index b078605..701fa80 100644 --- a/entangle/src/commands/shove.rs +++ b/entangle/src/commands/shove.rs @@ -1,22 +1,220 @@ //! Handler for `entangle shove`. //! -//! Convenience alias for: -//! git push origin --all -//! git push origin --tags +//! Convenience wrapper for: +//! ```text +//! git push origin --all +//! git push origin --tags +//! ``` //! -//! Because `entangle init` configures `origin` with two push URLs (GitHub and -//! Tangled), a single push command reaches both forges. `shove` is intended as -//! a one-time "push the whole thing" helper for the first sync after `init`, -//! when you want to make sure all branches and tags land on both forges. +//! Because `entangle init` configures `origin` with two `pushurl` entries +//! (GitHub and Tangled), a single push command reaches both forges. `shove` +//! is a "push the whole thing" helper — intended for the first full sync after +//! `init` when you want all branches and tags to land on both forges at once. //! -//! Errors from the push operations are surfaced with informative messages rather -//! than raw `gix` output. +//! ## Why `git push` rather than gix //! -//! Stub — implemented in Step 11. +//! gix's push API (v0.70) opens one connection per remote URL. It does not +//! automatically iterate multiple `pushurl` entries the way the `git` binary +//! does. Shelling out to `git push` is therefore both simpler *and* more +//! correct: git reads all `pushurl` lines from `.git/config` and pushes to +//! every one of them in a single command. +//! +//! Pre-push validation (repo detection, origin presence, empty-repo guard) +//! uses `gix` directly — those are local, offline checks that belong in the +//! gix domain. + +use std::path::Path; +use std::process::Command; + +// --------------------------------------------------------------------------- +// Public API +// --------------------------------------------------------------------------- /// Entry point called by `main.rs` for the `shove` subcommand. pub fn run() -> Result<(), Box> { - // Stub — implemented in Step 11. - println!("entangle shove: not yet implemented"); + let work_dir = std::env::current_dir()?; + run_with_paths(&work_dir) +} + +/// Core implementation, factored out for testability. +/// +/// All pre-push validation is done via `gix` (offline). The actual push +/// delegates to the `git` binary so that multiple `pushurl` entries in +/// `.git/config` are all reached by a single command. +pub fn run_with_paths(work_dir: &Path) -> Result<(), Box> { + // ── 1. Verify we're inside a git repository ─────────────────────────────── + // + // gix::discover searches upward from work_dir so that `entangle shove` + // works correctly whether run from the repo root or a subdirectory. + let repo = match gix::discover(work_dir) { + Ok(r) => r, + Err(_) => { + return Err( + "not a git repository. \ + Navigate to your project directory and run `entangle init` to set one up." + .into(), + ); + } + }; + + // ── 2. Verify that an origin remote is configured ───────────────────────── + // + // `entangle init` is responsible for setting up origin with both push URLs. + // If origin is missing we explain what to do rather than letting `git push` + // fail with a generic "no such remote" error. + let has_origin = match repo.try_find_remote_without_url_rewrite("origin") { + None | Some(Err(_)) => false, + Some(Ok(_)) => true, + }; + if !has_origin { + return Err( + "no 'origin' remote is configured. \ + Run `entangle init` to set up the GitHub and Tangled push remotes." + .into(), + ); + } + + // ── 3. Guard against empty repositories (no commits yet) ────────────────── + // + // An unborn HEAD (zero commits) causes `git push` to fail with an opaque + // refspec error. We catch it here and give a clear instruction instead. + if repo.head_id().is_err() { + return Err( + "no commits to push. \ + Make your first commit, then run `entangle shove` again." + .into(), + ); + } + + // ── 4. Push all branches to both forges ─────────────────────────────────── + // + // Because origin has two pushurl entries, `git push origin --all` sends + // to both GitHub and Tangled in one invocation. Git inherits the calling + // terminal's SSH agent, so auth works the same as a normal git push. + println!("Pushing all branches to both forges…"); + let branch_status = Command::new("git") + .args(["push", "origin", "--all"]) + .current_dir(work_dir) + .status()?; + if !branch_status.success() { + let code = branch_status.code().unwrap_or(1); + return Err(format!( + "branch push failed (exit {code}). \ + Check the output above for details, fix the issue, and re-run `entangle shove`." + ) + .into()); + } + + // ── 5. Push all tags to both forges ────────────────────────────────────── + println!("Pushing tags to both forges…"); + let tag_status = Command::new("git") + .args(["push", "origin", "--tags"]) + .current_dir(work_dir) + .status()?; + if !tag_status.success() { + let code = tag_status.code().unwrap_or(1); + return Err(format!( + "tag push failed (exit {code}). \ + Check the output above for details, fix the issue, and re-run `entangle shove`." + ) + .into()); + } + + println!("✓ All branches and tags pushed to both forges."); Ok(()) } + +// --------------------------------------------------------------------------- +// Tests +// --------------------------------------------------------------------------- + +#[cfg(test)] +mod tests { + use super::*; + use tempfile::TempDir; + + // ── Pre-push validation ─────────────────────────────────────────────────── + + #[test] + fn shove_errors_on_non_git_directory() { + let dir = TempDir::new().unwrap(); + let result = run_with_paths(dir.path()); + assert!(result.is_err(), "shove must error in a non-git directory"); + let msg = result.unwrap_err().to_string(); + assert!( + msg.contains("not a git repository"), + "error must mention 'not a git repository': {msg}" + ); + assert!( + msg.contains("entangle init"), + "error must suggest entangle init: {msg}" + ); + } + + #[test] + fn shove_errors_when_no_origin_configured() { + let dir = TempDir::new().unwrap(); + gix::init(dir.path()).unwrap(); + // No remotes configured — origin is absent. + let result = run_with_paths(dir.path()); + assert!(result.is_err(), "shove must error when origin is not configured"); + let msg = result.unwrap_err().to_string(); + assert!( + msg.contains("origin"), + "error must mention 'origin': {msg}" + ); + assert!( + msg.contains("entangle init"), + "error must suggest entangle init: {msg}" + ); + } + + #[test] + fn shove_errors_when_repo_has_no_commits() { + let dir = TempDir::new().unwrap(); + gix::init(dir.path()).unwrap(); + // Set up origin (simulating post-`entangle init` state) but add no commits. + crate::git::create_origin_remote( + dir.path(), + "git@github.com:user/repo.git", + &[ + "git@tangled.org:user.example.com/repo", + "git@github.com:user/repo.git", + ], + ) + .unwrap(); + let result = run_with_paths(dir.path()); + assert!(result.is_err(), "shove must error when there are no commits"); + let msg = result.unwrap_err().to_string(); + assert!( + msg.contains("commit"), + "error must mention 'commit': {msg}" + ); + } + + /// Verify that origin configured with a non-standard remote name does not + /// satisfy the origin check. + #[test] + fn shove_errors_when_only_upstream_remote_configured() { + let dir = TempDir::new().unwrap(); + gix::init(dir.path()).unwrap(); + // Add a remote named "upstream" — origin must still be absent. + use std::io::Write as _; + let config_path = dir.path().join(".git").join("config"); + let mut file = std::fs::OpenOptions::new() + .append(true) + .open(&config_path) + .unwrap(); + writeln!(file, "\n[remote \"upstream\"]").unwrap(); + writeln!(file, "\turl = git@github.com:user/repo.git").unwrap(); + writeln!(file, "\tfetch = +refs/heads/*:refs/remotes/upstream/*").unwrap(); + + let result = run_with_paths(dir.path()); + assert!(result.is_err()); + let msg = result.unwrap_err().to_string(); + assert!( + msg.contains("origin"), + "error must mention 'origin': {msg}" + ); + } +} diff --git a/entangle/src/config.rs b/entangle/src/config.rs index 693ee56..6102b53 100644 --- a/entangle/src/config.rs +++ b/entangle/src/config.rs @@ -45,7 +45,7 @@ use std::path::{Path, PathBuf}; /// - `-q` / `--quiet` → [`VerbosityLevel::Quiet`], regardless of config /// - `--debug` → [`VerbosityLevel::Debug`], regardless of config /// - (no flag) → use `config.verbosity_preference`, which defaults to -/// [`VerbosityLevel::Verbose`] +/// [`VerbosityLevel::Verbose`] #[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize, Default)] #[serde(rename_all = "lowercase")] pub enum VerbosityLevel { @@ -152,7 +152,7 @@ impl Config { /// - `quiet` flag → [`VerbosityLevel::Quiet`] /// - `debug` flag → [`VerbosityLevel::Debug`] /// - neither → the stored `verbosity_preference` (defaulting to - /// [`VerbosityLevel::Verbose`] if never set) + /// [`VerbosityLevel::Verbose`] if never set) /// /// `quiet` and `debug` are mutually exclusive at the CLI layer (enforced by /// clap's `conflicts_with`), so this function does not need to handle the diff --git a/entangle/src/git.rs b/entangle/src/git.rs index adc4055..9581e8c 100644 --- a/entangle/src/git.rs +++ b/entangle/src/git.rs @@ -145,6 +145,183 @@ pub fn get_origin_status(work_dir: &Path) -> Result Result<(), Box> { + use std::io::Write as _; + let config_path = work_dir.join(".git").join("config"); + let mut file = std::fs::OpenOptions::new() + .append(true) + .open(&config_path)?; + writeln!(file, "\n[remote \"origin\"]")?; + writeln!(file, "\turl = {fetch_url}")?; + writeln!(file, "\tfetch = +refs/heads/*:refs/remotes/origin/*")?; + for url in push_urls { + writeln!(file, "\tpushurl = {url}")?; + } + Ok(()) +} + +/// Replace the `url =` line in the existing `[remote "origin"]` section. +/// +/// Reads `.git/config`, rewrites the first `url` key it finds inside the +/// `[remote "origin"]` section, and writes the result back atomically. +/// +/// # When to call +/// +/// Call this when [`get_origin_status`] returns `Present` and the user has +/// confirmed that they want the fetch URL replaced (`replace_fetch_url == true`). +pub fn set_origin_fetch_url( + work_dir: &Path, + new_url: &str, +) -> Result<(), Box> { + let config_path = work_dir.join(".git").join("config"); + let content = std::fs::read_to_string(&config_path)?; + let modified = replace_url_in_origin_section(&content, new_url); + std::fs::write(&config_path, modified)?; + Ok(()) +} + +/// Append one or more `pushurl =` entries to the existing `[remote "origin"]` section. +/// +/// Reads `.git/config`, inserts the new `pushurl` lines just before the next +/// section header (or at the end of the file if `[remote "origin"]` is the last +/// section), and writes the result back. +/// +/// Pass only the push URLs that are not already present — this function does not +/// deduplicate. The caller is responsible for checking [`OriginStatus`]'s +/// `push_urls` vector first. +pub fn add_push_urls_to_origin( + work_dir: &Path, + push_urls: &[&str], +) -> Result<(), Box> { + if push_urls.is_empty() { + return Ok(()); + } + let config_path = work_dir.join(".git").join("config"); + let content = std::fs::read_to_string(&config_path)?; + let modified = insert_push_urls_in_config(&content, push_urls); + std::fs::write(&config_path, modified)?; + Ok(()) +} + +// --------------------------------------------------------------------------- +// Pure text-transform helpers +// --------------------------------------------------------------------------- + +/// Replace the `url = ...` line inside the `[remote "origin"]` section of a +/// git config string. +/// +/// Only the first `url` key encountered after the `[remote "origin"]` header is +/// replaced; subsequent sections and keys are untouched. If the section or key +/// is not found the original text is returned unchanged. +/// +/// Indentation is preserved: the replacement line uses the same leading +/// whitespace as the original. +fn replace_url_in_origin_section(config_text: &str, new_url: &str) -> String { + let section_header = "[remote \"origin\"]"; + let mut in_section = false; + let mut replaced = false; + let mut result = String::with_capacity(config_text.len() + new_url.len()); + + for line in config_text.lines() { + let trimmed = line.trim(); + + if trimmed.starts_with('[') { + in_section = trimmed == section_header; + result.push_str(line); + result.push('\n'); + continue; + } + + if in_section && !replaced && let Some(eq_pos) = trimmed.find('=') { + let key = trimmed[..eq_pos].trim(); + if key.eq_ignore_ascii_case("url") { + // Preserve the original indentation. + let indent_len = line.len() - line.trim_start().len(); + let indent = &line[..indent_len]; + result.push_str(indent); + result.push_str("url = "); + result.push_str(new_url); + result.push('\n'); + replaced = true; + continue; + } + } + + result.push_str(line); + result.push('\n'); + } + + result +} + +/// Insert `pushurl = ...` entries into the `[remote "origin"]` section. +/// +/// Lines are inserted immediately before the next section header, or at the end +/// of the string if `[remote "origin"]` is the last section. Push URLs are +/// written with a single leading tab (`\t`) to match git's own config style. +/// +/// If no `[remote "origin"]` section exists the string is returned unchanged. +/// An empty `push_urls` slice also returns the string unchanged. +fn insert_push_urls_in_config(config_text: &str, push_urls: &[&str]) -> String { + if push_urls.is_empty() { + return config_text.to_string(); + } + + let section_header = "[remote \"origin\"]"; + let mut in_section = false; + let mut inserted = false; + let mut result = String::with_capacity(config_text.len() + push_urls.len() * 60); + + for line in config_text.lines() { + let trimmed = line.trim(); + + if trimmed.starts_with('[') { + if in_section && !inserted { + // Leaving the origin section — insert before the next header. + for url in push_urls { + result.push_str("\tpushurl = "); + result.push_str(url); + result.push('\n'); + } + inserted = true; + } + in_section = trimmed == section_header; + } + + result.push_str(line); + result.push('\n'); + } + + // Origin section ran to end of file and we haven't inserted yet. + if in_section && !inserted { + for url in push_urls { + result.push_str("\tpushurl = "); + result.push_str(url); + result.push('\n'); + } + } + + result +} + /// Read all `pushurl` entries for `remote_name` from `.git/config`. /// /// Multiple `[remote ""]` sections are all visited — git config merges @@ -189,14 +366,12 @@ fn read_push_urls(work_dir: &Path, remote_name: &str) -> Vec { continue; } - if in_section { - if let Some(eq_pos) = trimmed.find('=') { - let key = trimmed[..eq_pos].trim(); - // Config keys are case-insensitive. - if key.eq_ignore_ascii_case("pushurl") { - let value = trimmed[eq_pos + 1..].trim(); - push_urls.push(value.to_string()); - } + if in_section && let Some(eq_pos) = trimmed.find('=') { + let key = trimmed[..eq_pos].trim(); + // Config keys are case-insensitive. + if key.eq_ignore_ascii_case("pushurl") { + let value = trimmed[eq_pos + 1..].trim(); + push_urls.push(value.to_string()); } } } @@ -480,6 +655,233 @@ mod tests { assert_eq!(urls, vec!["git@tangled.org:atdot.fyi/entangle"]); } + // ── replace_url_in_origin_section ───────────────────────────────────────── + + #[test] + fn replace_url_in_origin_section_replaces_url() { + let config = "[core]\n\trepositoryformatversion = 0\n\n[remote \"origin\"]\n\turl = git@github.com:old/repo.git\n\tfetch = +refs/heads/*:refs/remotes/origin/*\n"; + let result = replace_url_in_origin_section(config, "git@github.com:new/repo.git"); + assert!( + result.contains("\turl = git@github.com:new/repo.git"), + "must contain the new url: {result}" + ); + assert!( + !result.contains("url = git@github.com:old/repo.git"), + "must not contain old url: {result}" + ); + assert!(result.contains("[core]"), "must preserve [core] section"); + } + + #[test] + fn replace_url_in_origin_section_preserves_indentation() { + let config = "[remote \"origin\"]\n\t\turl = old\n"; + let result = replace_url_in_origin_section(config, "new"); + // The double-tab indentation from the original must be preserved. + assert!(result.contains("\t\turl = new"), "must preserve original indentation: {result}"); + } + + #[test] + fn replace_url_in_origin_section_does_not_touch_other_remotes() { + let config = "[remote \"upstream\"]\n\turl = upstream_url\n[remote \"origin\"]\n\turl = old_url\n"; + let result = replace_url_in_origin_section(config, "new_url"); + assert!(result.contains("upstream_url"), "must not modify upstream remote"); + assert!(result.contains("url = new_url"), "must update origin url"); + assert!(!result.contains("url = old_url"), "old url must be gone"); + } + + #[test] + fn replace_url_in_origin_section_returns_unchanged_when_section_absent() { + let config = "[core]\n\trepositoryformatversion = 0\n"; + let result = replace_url_in_origin_section(config, "some_url"); + // No [remote "origin"] section — the url must not appear in the result. + assert!( + !result.contains("some_url"), + "must not insert url when section is absent: {result}" + ); + assert!(result.contains("[core]"), "core section must still be present"); + } + + // ── insert_push_urls_in_config ──────────────────────────────────────────── + + #[test] + fn insert_push_urls_in_config_inserts_before_next_section() { + let config = "[remote \"origin\"]\n\turl = origin_url\n[branch \"main\"]\n\tremote = origin\n"; + let result = insert_push_urls_in_config(config, &["push_url_1", "push_url_2"]); + let push1_pos = result.find("pushurl = push_url_1").expect("push_url_1 must be in result"); + let branch_pos = result.find("[branch").expect("[branch] must still be in result"); + assert!( + push1_pos < branch_pos, + "pushurls must appear before [branch] header" + ); + assert!(result.contains("pushurl = push_url_2"), "push_url_2 must be present"); + } + + #[test] + fn insert_push_urls_in_config_appends_when_last_section() { + let config = "[remote \"origin\"]\n\turl = origin_url\n\tfetch = +refs/heads/*\n"; + let result = insert_push_urls_in_config(config, &["push_url_1"]); + assert!(result.contains("\tpushurl = push_url_1"), "must add pushurl at end"); + } + + #[test] + fn insert_push_urls_in_config_uses_tab_indent() { + let config = "[remote \"origin\"]\n\turl = url\n"; + let result = insert_push_urls_in_config(config, &["my_url"]); + assert!( + result.contains("\tpushurl = my_url"), + "pushurl must be tab-indented: {result}" + ); + } + + #[test] + fn insert_push_urls_in_config_returns_unchanged_for_empty_slice() { + let config = "[remote \"origin\"]\n\turl = url\n"; + let result = insert_push_urls_in_config(config, &[]); + assert_eq!(result, config, "empty push_urls must return unchanged string"); + } + + #[test] + fn insert_push_urls_in_config_returns_unchanged_when_section_absent() { + let config = "[core]\n\trepositoryformatversion = 0\n"; + let result = insert_push_urls_in_config(config, &["some_url"]); + // No origin section — string unchanged (modulo the line-by-line reconstruction). + assert!(!result.contains("pushurl"), "must not insert pushurl when section is absent"); + } + + // ── create_origin_remote ────────────────────────────────────────────────── + + #[test] + fn create_origin_remote_creates_section_with_fetch_and_push_urls() { + let dir = TempDir::new().unwrap(); + gix::init(dir.path()).unwrap(); + create_origin_remote( + dir.path(), + "git@github.com:user/repo.git", + &["git@github.com:user/repo.git", "git@tangled.org:user/repo"], + ) + .unwrap(); + + let status = get_origin_status(dir.path()).unwrap(); + match status { + OriginStatus::Present { fetch_url, push_urls } => { + assert_eq!(fetch_url, "git@github.com:user/repo.git"); + assert_eq!(push_urls.len(), 2, "must have both push URLs"); + assert!(push_urls.contains(&"git@github.com:user/repo.git".to_string())); + assert!(push_urls.contains(&"git@tangled.org:user/repo".to_string())); + } + OriginStatus::Absent => panic!("expected Present after create_origin_remote"), + } + } + + #[test] + fn create_origin_remote_with_no_push_urls_creates_section() { + let dir = TempDir::new().unwrap(); + gix::init(dir.path()).unwrap(); + create_origin_remote(dir.path(), "git@github.com:user/repo.git", &[]).unwrap(); + + let status = get_origin_status(dir.path()).unwrap(); + match status { + OriginStatus::Present { fetch_url, push_urls } => { + assert_eq!(fetch_url, "git@github.com:user/repo.git"); + assert!(push_urls.is_empty(), "no push URLs should be configured"); + } + OriginStatus::Absent => panic!("expected Present"), + } + } + + // ── set_origin_fetch_url ────────────────────────────────────────────────── + + #[test] + fn set_origin_fetch_url_replaces_existing_url() { + let dir = TempDir::new().unwrap(); + gix::init(dir.path()).unwrap(); + append_origin_remote(dir.path(), "git@github.com:old/repo.git", &[]); + + set_origin_fetch_url(dir.path(), "git@github.com:new/repo.git").unwrap(); + + let status = get_origin_status(dir.path()).unwrap(); + match status { + OriginStatus::Present { fetch_url, .. } => { + assert_eq!( + fetch_url, "git@github.com:new/repo.git", + "fetch URL must be updated" + ); + } + OriginStatus::Absent => panic!("expected Present"), + } + } + + #[test] + fn set_origin_fetch_url_preserves_push_urls() { + let dir = TempDir::new().unwrap(); + gix::init(dir.path()).unwrap(); + append_origin_remote( + dir.path(), + "git@github.com:old/repo.git", + &["git@github.com:old/repo.git"], + ); + + set_origin_fetch_url(dir.path(), "git@github.com:new/repo.git").unwrap(); + + let status = get_origin_status(dir.path()).unwrap(); + match status { + OriginStatus::Present { fetch_url, push_urls } => { + assert_eq!(fetch_url, "git@github.com:new/repo.git"); + assert_eq!(push_urls, vec!["git@github.com:old/repo.git"]); + } + OriginStatus::Absent => panic!("expected Present"), + } + } + + // ── add_push_urls_to_origin ─────────────────────────────────────────────── + + #[test] + fn add_push_urls_to_origin_appends_to_existing_remote() { + let dir = TempDir::new().unwrap(); + gix::init(dir.path()).unwrap(); + append_origin_remote(dir.path(), "git@github.com:user/repo.git", &[]); + + add_push_urls_to_origin( + dir.path(), + &["git@github.com:user/repo.git", "git@tangled.org:user/repo"], + ) + .unwrap(); + + let urls = read_push_urls(dir.path(), "origin"); + assert_eq!(urls.len(), 2, "must have both push URLs"); + assert!(urls.contains(&"git@github.com:user/repo.git".to_string())); + assert!(urls.contains(&"git@tangled.org:user/repo".to_string())); + } + + #[test] + fn add_push_urls_to_origin_is_noop_for_empty_slice() { + let dir = TempDir::new().unwrap(); + gix::init(dir.path()).unwrap(); + append_origin_remote(dir.path(), "git@github.com:user/repo.git", &[]); + + add_push_urls_to_origin(dir.path(), &[]).unwrap(); + + let urls = read_push_urls(dir.path(), "origin"); + assert!(urls.is_empty(), "no push URLs should be added from an empty slice"); + } + + #[test] + fn add_push_urls_to_origin_preserves_fetch_url() { + let dir = TempDir::new().unwrap(); + gix::init(dir.path()).unwrap(); + append_origin_remote(dir.path(), "git@github.com:user/repo.git", &[]); + + add_push_urls_to_origin(dir.path(), &["git@tangled.org:user/repo"]).unwrap(); + + let status = get_origin_status(dir.path()).unwrap(); + match status { + OriginStatus::Present { fetch_url, .. } => { + assert_eq!(fetch_url, "git@github.com:user/repo.git", "fetch URL must be preserved"); + } + OriginStatus::Absent => panic!("expected Present"), + } + } + #[test] fn read_push_urls_collects_across_multiple_sections() { // Git allows the same section header to appear multiple times; diff --git a/entangle/tests/init_integration.rs b/entangle/tests/init_integration.rs index 39ed88c..fe4cbb6 100644 --- a/entangle/tests/init_integration.rs +++ b/entangle/tests/init_integration.rs @@ -58,11 +58,16 @@ fn write_valid_config(path: &Path) { } /// Spawn `entangle init` with the given extra args, config path, and work dir. +/// +/// Sets `ENTANGLE_SKIP_REMOTE_CHECK=1` so the binary skips SSH ls-refs calls. +/// Integration tests exercise everything except live network reachability; +/// the network path is covered by the `#[ignore]` tests in `remote.rs`. fn run_init(args: &[&str], config_path: &Path, work_dir: &Path) -> Output { Command::new(env!("CARGO_BIN_EXE_entangle")) .arg("init") .args(args) .env("ENTANGLE_CONFIG_PATH", config_path) + .env("ENTANGLE_SKIP_REMOTE_CHECK", "1") .current_dir(work_dir) .output() .expect("failed to spawn entangle init") @@ -541,6 +546,7 @@ mod pty_overwrite_tests { cmd.arg(a); } cmd.env("ENTANGLE_CONFIG_PATH", config_path); + cmd.env("ENTANGLE_SKIP_REMOTE_CHECK", "1"); cmd.current_dir(work_dir); cmd } diff --git a/entangle/tests/shove_integration.rs b/entangle/tests/shove_integration.rs new file mode 100644 index 0000000..9b5074f --- /dev/null +++ b/entangle/tests/shove_integration.rs @@ -0,0 +1,259 @@ +//! Integration tests for `entangle shove`. +//! +//! These tests spawn the compiled `entangle` binary and verify its exit code +//! and output for the pre-push validation paths. The end-to-end push path is +//! tested with two local bare repositories as fake remotes (no network required). + +use std::path::Path; +use std::process::{Command, Output}; + +// --------------------------------------------------------------------------- +// Helper +// --------------------------------------------------------------------------- + +/// Run `entangle shove` in `work_dir` and return the raw [`Output`]. +fn run_shove(work_dir: &Path) -> Output { + Command::new(env!("CARGO_BIN_EXE_entangle")) + .arg("shove") + .current_dir(work_dir) + .output() + .expect("failed to spawn entangle shove") +} + +/// Initialize a bare git repo in `dir` using the system `git` binary. +/// +/// Using the git binary here (rather than gix) is intentional: these are +/// integration tests verifying binary behaviour, and setting up a minimal +/// git repo is faster and more predictable with the installed git toolchain. +fn git_init(dir: &Path) { + let status = Command::new("git") + .args(["init"]) + .current_dir(dir) + .output() + .expect("git init must succeed"); + assert!(status.status.success(), "git init failed"); +} + +/// Add an `origin` remote to the git repo in `dir`. +fn git_remote_add_origin(dir: &Path, url: &str) { + let status = Command::new("git") + .args(["remote", "add", "origin", url]) + .current_dir(dir) + .output() + .expect("git remote add must succeed"); + assert!(status.status.success(), "git remote add failed"); +} + +// --------------------------------------------------------------------------- +// Error-path integration tests +// --------------------------------------------------------------------------- + +/// `entangle shove` in a plain directory (no `.git/`) must exit non-zero and +/// explain that the directory is not a git repository. +#[test] +fn shove_in_non_git_directory_exits_nonzero() { + let dir = tempfile::TempDir::new().unwrap(); + let out = run_shove(dir.path()); + + assert!( + !out.status.success(), + "shove must exit non-zero in a non-git directory; exit: {:?}", + out.status.code() + ); + + // Error message is written to stderr by main.rs ("Error: …"). + let stderr = String::from_utf8_lossy(&out.stderr); + assert!( + stderr.contains("git repository"), + "stderr must mention 'git repository': {stderr}" + ); +} + +/// `entangle shove` in a git repo where no `origin` remote is configured must +/// exit non-zero and suggest `entangle init`. +#[test] +fn shove_without_origin_remote_exits_nonzero() { + let dir = tempfile::TempDir::new().unwrap(); + git_init(dir.path()); + // Intentionally no `git remote add origin …`. + + let out = run_shove(dir.path()); + + assert!( + !out.status.success(), + "shove must exit non-zero when origin is not configured; exit: {:?}", + out.status.code() + ); + + let stderr = String::from_utf8_lossy(&out.stderr); + assert!( + stderr.contains("origin"), + "stderr must mention 'origin': {stderr}" + ); + assert!( + stderr.contains("entangle init"), + "stderr must suggest 'entangle init': {stderr}" + ); +} + +/// `entangle shove` in an empty repository (origin configured, but zero +/// commits) must exit non-zero and tell the user to make a commit first. +#[test] +fn shove_in_empty_repo_exits_nonzero() { + let dir = tempfile::TempDir::new().unwrap(); + git_init(dir.path()); + git_remote_add_origin(dir.path(), "git@github.com:user/repo.git"); + // Intentionally no commits. + + let out = run_shove(dir.path()); + + assert!( + !out.status.success(), + "shove must exit non-zero when there are no commits; exit: {:?}", + out.status.code() + ); + + let stderr = String::from_utf8_lossy(&out.stderr); + assert!( + stderr.contains("commit"), + "stderr must mention 'commit': {stderr}" + ); +} + +// --------------------------------------------------------------------------- +// Local push tests (no SSH — use bare repos as fake remotes) +// --------------------------------------------------------------------------- + +/// Full end-to-end push to two local bare repositories acting as fake remotes. +/// +/// Uses `file://` URLs — no SSH credentials or network required. Exercises the +/// entire `entangle shove` code path: repo detection, origin check, commit guard, +/// branch push (`--all`), tag push (`--tags`), and the dual-pushurl forwarding. +#[test] +fn shove_pushes_to_two_local_remotes() { + // Create two bare repos that will act as the two "forges". + let forge_a = tempfile::TempDir::new().unwrap(); + let forge_b = tempfile::TempDir::new().unwrap(); + for forge_dir in [forge_a.path(), forge_b.path()] { + let s = Command::new("git") + .args(["init", "--bare"]) + .current_dir(forge_dir) + .output() + .unwrap(); + assert!(s.status.success(), "git init --bare failed"); + } + + // Create the working repo. + let work = tempfile::TempDir::new().unwrap(); + git_init(work.path()); + + // Configure user identity so `git commit` works without global config. + for (k, v) in [("user.email", "test@example.com"), ("user.name", "Test")] { + Command::new("git") + .args(["config", k, v]) + .current_dir(work.path()) + .output() + .unwrap(); + } + + // Make a commit and a tag. + std::fs::write(work.path().join("README.md"), b"# test\n").unwrap(); + Command::new("git").args(["add", "."]).current_dir(work.path()).output().unwrap(); + Command::new("git") + .args(["commit", "-m", "init"]) + .current_dir(work.path()) + .output() + .unwrap(); + Command::new("git") + .args(["tag", "v0.1.0"]) + .current_dir(work.path()) + .output() + .unwrap(); + + // Configure origin with the fetch URL pointing to forge_a, and both + // pushurl entries pointing at forge_a and forge_b. + let url_a = format!("file://{}", forge_a.path().display()); + let url_b = format!("file://{}", forge_b.path().display()); + git_remote_add_origin(work.path(), &url_a); + // Replace the implicit push URL with two explicit pushurl entries. + Command::new("git") + .args(["remote", "set-url", "--add", "--push", "origin", &url_a]) + .current_dir(work.path()) + .output() + .unwrap(); + Command::new("git") + .args(["remote", "set-url", "--add", "--push", "origin", &url_b]) + .current_dir(work.path()) + .output() + .unwrap(); + + // Run `entangle shove` — it must succeed and push to both forges. + let out = run_shove(work.path()); + let stdout = String::from_utf8_lossy(&out.stdout); + let stderr = String::from_utf8_lossy(&out.stderr); + assert!( + out.status.success(), + "shove must succeed with local bare remotes; stdout: {stdout}; stderr: {stderr}" + ); + assert!( + stdout.contains("✓"), + "stdout must show success indicator: {stdout}" + ); + + // Verify both bare repos received the branch and the tag. + for forge_dir in [forge_a.path(), forge_b.path()] { + let log = Command::new("git") + .args(["log", "--oneline"]) + .current_dir(forge_dir) + .output() + .unwrap(); + assert!( + log.status.success(), + "git log must succeed in bare repo at {}", + forge_dir.display() + ); + let log_out = String::from_utf8_lossy(&log.stdout); + assert!( + log_out.contains("init"), + "bare repo must contain the commit; log: {log_out}" + ); + + let tags = Command::new("git") + .args(["tag"]) + .current_dir(forge_dir) + .output() + .unwrap(); + let tags_out = String::from_utf8_lossy(&tags.stdout); + assert!( + tags_out.contains("v0.1.0"), + "bare repo must contain the tag; tags: {tags_out}" + ); + } +} + +// --------------------------------------------------------------------------- +// Argument-parsing tests +// --------------------------------------------------------------------------- + +/// `entangle shove` takes no arguments; passing one must exit non-zero with a +/// usage/error message. clap handles this automatically. +#[test] +fn shove_with_unexpected_argument_exits_nonzero() { + let dir = tempfile::TempDir::new().unwrap(); + let out = Command::new(env!("CARGO_BIN_EXE_entangle")) + .args(["shove", "unexpected-arg"]) + .current_dir(dir.path()) + .output() + .expect("failed to spawn entangle shove"); + + assert!( + !out.status.success(), + "shove must exit non-zero when passed an unexpected argument" + ); + // clap writes usage errors to stderr. + let stderr = String::from_utf8_lossy(&out.stderr); + assert!( + !stderr.is_empty(), + "stderr must contain an error message for unexpected argument" + ); +}