entangle-mirror 0.1.2

Easy setup for mirroring GitHub repos to Tangled.org in one command
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
//! Integration tests for `entangle init` — Steps 8 and 9.
//!
//! ## What these tests cover
//!
//! **Step 8 — piped-stdin tests (all platforms)**
//! - Fresh directory: `entangle init` creates a `.git` repo and prints the
//!   initialization message.
//! - Idempotency: running `entangle init` twice in the same directory does
//!   not re-initialize; the init message is absent on the second run.
//! - Missing config: the command errors with an actionable message pointing
//!   the user to `entangle setup`.
//! - `.gitignore` / `README.md` suggestions appear when those files are absent
//!   and do not appear when they are present.
//! - Valid alias: `entangle init myrepo myalias` is accepted without error.
//!
//! **Step 9 — PTY tests (`#[cfg(unix)]` via rexpect)**
//! - Replace=yes: user accepts the replace prompt → continues to URL preview.
//! - Replace=no, proceed=yes: user keeps existing origin → warning message shown.
//! - Replace=no, proceed=no: user aborts → "Init cancelled", no URL preview.
//! - Early exit: both push URLs already present → success message, no prompts.
//!
//! ## Why piped stdin for Step 8 tests
//!
//! All Step 8 tests pass the repo name as a CLI positional argument, so
//! `dialoguer` is never invoked. The binary runs non-interactively and can be
//! driven with `Command::output()`.
//!
//! ## Why rexpect for Step 9 prompt tests
//!
//! `dialoguer::Confirm` requires a real TTY. `rexpect` spawns the binary in a
//! pseudo-terminal so dialoguer sees a TTY and renders its prompts. PTY tests
//! are gated on `#[cfg(unix)]` because rexpect uses Unix PTY APIs.
//!
//! ## Isolation
//!
//! `ENTANGLE_CONFIG_PATH` is set to a per-test temp file. The binary is run
//! with `.current_dir(work_dir)` so it sees the temp directory as cwd.

use std::path::{Path, PathBuf};
use std::process::{Command, Output};
use tempfile::TempDir;

// ---------------------------------------------------------------------------
// Shared helpers
// ---------------------------------------------------------------------------

/// Write a minimal valid config JSON to `path`, creating parent dirs as needed.
fn write_valid_config(path: &Path) {
    if let Some(parent) = path.parent() {
        std::fs::create_dir_all(parent).unwrap();
    }
    let json = serde_json::json!({
        "github_username": "cyrusae",
        "tangled_username": "atdot.fyi",
        "origin_preference": "github",
    });
    std::fs::write(path, serde_json::to_string_pretty(&json).unwrap()).unwrap();
}

/// 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")
}

fn stdout(o: &Output) -> String {
    String::from_utf8_lossy(&o.stdout).into_owned()
}
fn stderr(o: &Output) -> String {
    String::from_utf8_lossy(&o.stderr).into_owned()
}

/// Create a TempDir, config path, and a fresh (non-repo) work subdirectory.
fn setup_dirs() -> (TempDir, PathBuf, PathBuf) {
    let dir = TempDir::new().unwrap();
    let config_path = dir.path().join("config.json");
    let work_dir = dir.path().join("work");
    std::fs::create_dir(&work_dir).unwrap();
    write_valid_config(&config_path);
    (dir, config_path, work_dir)
}

// ---------------------------------------------------------------------------
// Git repo initialization
// ---------------------------------------------------------------------------

#[test]
fn fresh_init_creates_dot_git_directory() {
    let (_dir, config_path, work_dir) = setup_dirs();

    let output = run_init(&["entangle"], &config_path, &work_dir);
    assert!(
        output.status.success(),
        "init must succeed\nstdout: {}\nstderr: {}",
        stdout(&output),
        stderr(&output)
    );
    assert!(
        work_dir.join(".git").exists(),
        ".git must exist after fresh init"
    );
}

#[test]
fn fresh_init_prints_initialization_message() {
    let (_dir, config_path, work_dir) = setup_dirs();

    let output = run_init(&["entangle"], &config_path, &work_dir);
    let out = stdout(&output);
    assert!(
        out.contains("nitializ"),
        "fresh init must print initialization message\nstdout: {out}"
    );
}

#[test]
fn second_init_does_not_print_initialization_message() {
    let (_dir, config_path, work_dir) = setup_dirs();

    // First run — initializes.
    run_init(&["entangle"], &config_path, &work_dir);

    // Second run — must not re-initialize.
    let output = run_init(&["entangle"], &config_path, &work_dir);
    assert!(
        output.status.success(),
        "second init must succeed\nstdout: {}\nstderr: {}",
        stdout(&output),
        stderr(&output)
    );
    let out = stdout(&output);
    assert!(
        !out.contains("nitializ"),
        "second run must not print initialization message\nstdout: {out}"
    );
}

#[test]
fn second_init_exits_successfully() {
    let (_dir, config_path, work_dir) = setup_dirs();
    run_init(&["entangle"], &config_path, &work_dir);
    let output = run_init(&["entangle"], &config_path, &work_dir);
    assert!(output.status.success(), "second init must exit 0");
}

// ---------------------------------------------------------------------------
// Config validation
// ---------------------------------------------------------------------------

#[test]
fn missing_config_exits_nonzero() {
    let dir = TempDir::new().unwrap();
    let config_path = dir.path().join("nonexistent.json");
    let work_dir = dir.path().join("work");
    std::fs::create_dir(&work_dir).unwrap();

    let output = run_init(&["entangle"], &config_path, &work_dir);
    assert!(
        !output.status.success(),
        "must exit non-zero when config is missing"
    );
}

#[test]
fn missing_config_mentions_setup_in_output() {
    let dir = TempDir::new().unwrap();
    let config_path = dir.path().join("nonexistent.json");
    let work_dir = dir.path().join("work");
    std::fs::create_dir(&work_dir).unwrap();

    let output = run_init(&["entangle"], &config_path, &work_dir);
    let err = stderr(&output);
    assert!(
        err.contains("setup"),
        "error output must mention 'setup'\nstderr: {err}"
    );
}

#[test]
fn missing_config_does_not_create_git_repo() {
    let dir = TempDir::new().unwrap();
    let config_path = dir.path().join("nonexistent.json");
    let work_dir = dir.path().join("work");
    std::fs::create_dir(&work_dir).unwrap();

    run_init(&["entangle"], &config_path, &work_dir);
    assert!(
        !work_dir.join(".git").exists(),
        ".git must not be created when config is missing"
    );
}

// ---------------------------------------------------------------------------
// .gitignore and README.md suggestions
// ---------------------------------------------------------------------------

#[test]
fn suggests_gitignore_when_absent() {
    let (_dir, config_path, work_dir) = setup_dirs();
    // No .gitignore in work_dir.

    let output = run_init(&["entangle"], &config_path, &work_dir);
    let out = stdout(&output);
    assert!(
        out.contains(".gitignore"),
        "must suggest adding .gitignore when absent\nstdout: {out}"
    );
}

#[test]
fn no_gitignore_suggestion_when_present() {
    let (_dir, config_path, work_dir) = setup_dirs();
    std::fs::write(work_dir.join(".gitignore"), b"target/\n*.tmp\n").unwrap();

    let output = run_init(&["entangle"], &config_path, &work_dir);
    let out = stdout(&output);
    assert!(
        !out.contains("Add a .gitignore"),
        ".gitignore suggestion must not appear when file already exists\nstdout: {out}"
    );
}

#[test]
fn suggests_readme_when_absent() {
    let (_dir, config_path, work_dir) = setup_dirs();
    // No README.md in work_dir.

    let output = run_init(&["entangle"], &config_path, &work_dir);
    let out = stdout(&output);
    assert!(
        out.contains("README.md"),
        "must suggest adding README.md when absent\nstdout: {out}"
    );
}

#[test]
fn no_readme_suggestion_when_present() {
    let (_dir, config_path, work_dir) = setup_dirs();
    std::fs::write(work_dir.join("README.md"), b"# My Project\n").unwrap();

    let output = run_init(&["entangle"], &config_path, &work_dir);
    let out = stdout(&output);
    assert!(
        !out.contains("Add a README.md"),
        "README.md suggestion must not appear when file already exists\nstdout: {out}"
    );
}

// ---------------------------------------------------------------------------
// URL preview
// ---------------------------------------------------------------------------

#[test]
fn output_contains_github_url() {
    let (_dir, config_path, work_dir) = setup_dirs();

    let output = run_init(&["entangle"], &config_path, &work_dir);
    let out = stdout(&output);
    assert!(
        out.contains("github.com"),
        "output must show the GitHub URL\nstdout: {out}"
    );
}

#[test]
fn output_contains_tangled_url() {
    let (_dir, config_path, work_dir) = setup_dirs();

    let output = run_init(&["entangle"], &config_path, &work_dir);
    let out = stdout(&output);
    assert!(
        out.contains("tangled.org"),
        "output must show the Tangled URL\nstdout: {out}"
    );
}

#[test]
fn output_contains_repo_name() {
    let (_dir, config_path, work_dir) = setup_dirs();

    let output = run_init(&["my-project"], &config_path, &work_dir);
    let out = stdout(&output);
    assert!(
        out.contains("my-project"),
        "output must include the repo name\nstdout: {out}"
    );
}

// ---------------------------------------------------------------------------
// Alias
// ---------------------------------------------------------------------------

#[test]
fn alias_accepted_and_appears_in_mirror_url() {
    let (_dir, config_path, work_dir) = setup_dirs();

    let output = run_init(&["entangle", "my-alias"], &config_path, &work_dir);
    assert!(
        output.status.success(),
        "must succeed with valid alias\nstdout: {}\nstderr: {}",
        stdout(&output),
        stderr(&output)
    );
    let out = stdout(&output);
    // Mirror URL should use the alias, not the repo name.
    assert!(
        out.contains("my-alias"),
        "alias must appear in the mirror URL\nstdout: {out}"
    );
}

#[test]
fn invalid_repo_name_exits_nonzero() {
    let (_dir, config_path, work_dir) = setup_dirs();

    let output = run_init(&["-bad-name"], &config_path, &work_dir);
    assert!(
        !output.status.success(),
        "invalid repo name must exit non-zero"
    );
}

#[test]
fn invalid_repo_name_does_not_create_git_repo() {
    let (_dir, config_path, work_dir) = setup_dirs();

    run_init(&["-bad-name"], &config_path, &work_dir);
    assert!(
        !work_dir.join(".git").exists(),
        ".git must not be created when repo name is invalid"
    );
}

// ---------------------------------------------------------------------------
// Argument-parsing edge cases
// ---------------------------------------------------------------------------

/// `entangle init` accepts at most two positional arguments (REPO and ALIAS).
/// A third positional argument must be rejected by clap with a non-zero exit.
#[test]
fn init_with_three_positional_args_exits_nonzero() {
    let (_dir, config_path, work_dir) = setup_dirs();

    let output = run_init(
        &["entangle", "my-alias", "extra-arg"],
        &config_path,
        &work_dir,
    );
    assert!(
        !output.status.success(),
        "init with 3+ positional args must exit non-zero\nstdout: {}\nstderr: {}",
        stdout(&output),
        stderr(&output)
    );
    // clap writes usage errors to stderr.
    let err = stderr(&output);
    assert!(
        !err.is_empty(),
        "stderr must contain an error message for the unexpected argument"
    );
}

// ---------------------------------------------------------------------------
// Case normalisation (end-to-end in the binary)
// ---------------------------------------------------------------------------

/// Repo name supplied in uppercase must be lowercased before use.
///
/// This confirms that sanitize-before-validate runs in the binary's argument
/// path, not just in the unit-tested validate functions.
#[test]
fn init_uppercase_repo_name_is_normalised() {
    let (_dir, config_path, work_dir) = setup_dirs();

    // "ENTANGLE" → should be normalised to "entangle".
    let output = run_init(&["ENTANGLE"], &config_path, &work_dir);
    assert!(
        output.status.success(),
        "uppercase repo name must succeed after normalisation\nstdout: {}\nstderr: {}",
        stdout(&output),
        stderr(&output)
    );

    let out = stdout(&output);
    // The URL preview must use the lowercased form.
    assert!(
        out.contains("entangle"),
        "output must use the normalised (lowercase) repo name\nstdout: {out}"
    );
    // The uppercase original must not appear literally in any URL.
    assert!(
        !out.contains("ENTANGLE"),
        "output must not contain the un-normalised uppercase name\nstdout: {out}"
    );
}

// ---------------------------------------------------------------------------
// Step 9 — early exit (all platforms, piped stdin)
//
// These tests exercise the "both push URLs already present" path which does
// NOT trigger any dialoguer prompts — the binary exits before reaching them.
// ---------------------------------------------------------------------------

/// Append an `[remote "origin"]` section to `.git/config`, optionally with
/// push URLs. Appends so that gix's core config entries are preserved.
fn append_origin_remote(work_dir: &Path, fetch_url: &str, push_urls: &[&str]) {
    use std::io::Write as _;
    let cfg = work_dir.join(".git").join("config");
    let mut f = std::fs::OpenOptions::new()
        .append(true)
        .open(&cfg)
        .expect("must open .git/config");
    writeln!(f, "\n[remote \"origin\"]").unwrap();
    writeln!(f, "\turl = {fetch_url}").unwrap();
    writeln!(f, "\tfetch = +refs/heads/*:refs/remotes/origin/*").unwrap();
    for u in push_urls {
        writeln!(f, "\tpushurl = {u}").unwrap();
    }
}

/// Read all `pushurl` lines under the `[remote "origin"]` section in `.git/config`.
fn read_origin_push_urls(work_dir: &Path) -> Vec<String> {
    let cfg = work_dir.join(".git").join("config");
    let content = std::fs::read_to_string(cfg).expect("must read .git/config");
    let mut in_section = false;
    let mut push_urls = Vec::new();
    for line in content.lines() {
        let trimmed = line.trim();
        if trimmed.starts_with('[') {
            in_section = trimmed == "[remote \"origin\"]";
            continue;
        }
        if in_section
            && trimmed.starts_with("pushurl =")
            && let Some(val) = trimmed.split('=').nth(1)
        {
            push_urls.push(val.trim().to_string());
        }
    }
    push_urls
}

#[test]
fn early_exit_when_both_push_urls_already_configured() {
    let (_dir, config_path, work_dir) = setup_dirs();

    // First init to create the git repo (no origin yet).
    let first = run_init(&["entangle"], &config_path, &work_dir);
    assert!(first.status.success(), "first init must succeed");

    // Manually add origin with both push URLs already set.
    append_origin_remote(
        &work_dir,
        "git@github.com:cyrusae/entangle.git",
        &[
            "git@github.com:cyrusae/entangle.git",
            "git@tangled.org:atdot.fyi/entangle",
        ],
    );

    let output = run_init(&["entangle"], &config_path, &work_dir);
    assert!(
        output.status.success(),
        "must exit 0 when already configured\nstdout: {}\nstderr: {}",
        stdout(&output),
        stderr(&output)
    );
    let out = stdout(&output);
    assert!(
        out.contains("already configured"),
        "output must mention 'already configured'\nstdout: {out}"
    );
}

#[test]
fn init_with_only_mirror_push_url_configured_adds_origin_push_url() {
    let (_dir, config_path, work_dir) = setup_dirs();

    // Create the git repo manually (no origin remote yet).
    gix::init(&work_dir).expect("gix::init must succeed");

    // Manually add origin with ONLY the Tangled mirror push URL configured.
    append_origin_remote(
        &work_dir,
        "git@github.com:cyrusae/entangle.git",
        &["git@tangled.org:atdot.fyi/entangle"],
    );

    // Run init. It should detect that one push URL is missing and add it.
    let output = run_init(&["entangle"], &config_path, &work_dir);
    assert!(
        output.status.success(),
        "init must succeed on partial config"
    );

    // Both push URLs must now be configured in the correct order.
    let push_urls = read_origin_push_urls(&work_dir);
    assert_eq!(
        push_urls,
        vec![
            "git@tangled.org:atdot.fyi/entangle",
            "git@github.com:cyrusae/entangle.git",
        ]
    );
}

#[test]
fn init_with_only_origin_push_url_configured_adds_mirror_push_url() {
    let (_dir, config_path, work_dir) = setup_dirs();

    // Create the git repo manually.
    gix::init(&work_dir).expect("gix::init must succeed");

    // Manually add origin with ONLY the GitHub origin push URL configured.
    append_origin_remote(
        &work_dir,
        "git@github.com:cyrusae/entangle.git",
        &["git@github.com:cyrusae/entangle.git"],
    );

    // Run init. It should detect that one push URL is missing and add it.
    let output = run_init(&["entangle"], &config_path, &work_dir);
    assert!(
        output.status.success(),
        "init must succeed on partial config"
    );

    // Since origin was already configured first, mirror is appended second.
    let push_urls = read_origin_push_urls(&work_dir);
    assert_eq!(
        push_urls,
        vec![
            "git@github.com:cyrusae/entangle.git",
            "git@tangled.org:atdot.fyi/entangle",
        ]
    );
}

// ---------------------------------------------------------------------------
// Verbosity flags (-q / --debug)
//
// These tests exercise the verbosity system without a PTY — they pass the
// repo name as a CLI arg so dialoguer is never invoked, making the binary
// fully non-interactive and driveable with Command::output().
// ---------------------------------------------------------------------------

#[test]
fn quiet_flag_suppresses_informational_stdout() {
    let (_dir, config_path, work_dir) = setup_dirs();

    // Run with -q. Tips and URL preview must not appear on stdout.
    let output = run_init(&["entangle", "-q"], &config_path, &work_dir);
    assert!(
        output.status.success(),
        "quiet init must succeed\nstdout: {}\nstderr: {}",
        stdout(&output),
        stderr(&output)
    );

    let out = stdout(&output);
    // None of the chatty verbose-only messages should appear.
    assert!(
        !out.contains("Tip:"),
        "quiet mode must suppress README/gitignore tips\nstdout: {out}"
    );
    assert!(
        !out.contains("Configuring remotes"),
        "quiet mode must suppress URL preview\nstdout: {out}"
    );
    assert!(
        !out.contains("Git repository"),
        "quiet mode must suppress git-init status\nstdout: {out}"
    );
}

#[test]
fn verbose_flag_not_needed_for_default_output() {
    // No flag → verbose by default. Tips and URL preview should appear.
    let (_dir, config_path, work_dir) = setup_dirs();

    let output = run_init(&["entangle"], &config_path, &work_dir);
    assert!(output.status.success(), "default init must succeed");

    let out = stdout(&output);
    assert!(
        out.contains("Configuring remotes"),
        "default (verbose) mode must include URL preview\nstdout: {out}"
    );
}

#[test]
fn debug_flag_emits_debug_lines() {
    let (_dir, config_path, work_dir) = setup_dirs();

    let output = run_init(&["entangle", "--debug"], &config_path, &work_dir);
    assert!(
        output.status.success(),
        "debug init must succeed\nstdout: {}\nstderr: {}",
        stdout(&output),
        stderr(&output)
    );

    let out = stdout(&output);
    // Debug-level lines are bracketed with [debug].
    assert!(
        out.contains("[debug]"),
        "debug mode must emit [debug] lines\nstdout: {out}"
    );
    // Debug is a superset of verbose — URL preview must still appear.
    assert!(
        out.contains("Configuring remotes"),
        "debug mode must include verbose URL preview\nstdout: {out}"
    );
}

#[test]
fn config_verbosity_preference_quiet_suppresses_output() {
    // Write a config with verbosity_preference = "quiet", then run without any
    // CLI flag — the stored preference should take effect.
    let dir = TempDir::new().unwrap();
    let config_path = dir.path().join("config.json");
    let work_dir = dir.path().join("work");
    std::fs::create_dir(&work_dir).unwrap();

    // Write a config with verbosity_preference explicitly set to quiet.
    let json = serde_json::json!({
        "github_username": "cyrusae",
        "tangled_username": "atdot.fyi",
        "origin_preference": "github",
        "verbosity_preference": "quiet",
    });
    std::fs::write(&config_path, serde_json::to_string_pretty(&json).unwrap()).unwrap();

    let output = run_init(&["entangle"], &config_path, &work_dir);
    assert!(
        output.status.success(),
        "quiet-config init must succeed\nstdout: {}\nstderr: {}",
        stdout(&output),
        stderr(&output)
    );

    let out = stdout(&output);
    assert!(
        !out.contains("Configuring remotes"),
        "quiet config preference must suppress URL preview\nstdout: {out}"
    );
}

// ---------------------------------------------------------------------------
// Step 9 — overwrite prompt tests (Unix only, PTY via rexpect)
//
// These tests require a real TTY because `dialoguer::Confirm` won't render
// its prompt over piped stdin. rexpect spawns the binary in a
// pseudo-terminal so dialoguer behaves as it would for a real user.
// ---------------------------------------------------------------------------

#[cfg(unix)]
mod pty_overwrite_tests {
    use super::*;
    use rexpect::session::spawn_command;

    /// Timeout for rexpect operations (ms). Long enough for a debug build.
    const TIMEOUT_MS: Option<u64> = Some(10_000);

    /// Shared PTY test setup: valid config + git repo with a GitLab origin
    /// (a URL that doesn't match what `entangle init entangle` would generate
    /// with the standard test config).
    fn setup_with_gitlab_origin() -> (TempDir, PathBuf, PathBuf) {
        let (dir, config_path, work_dir) = setup_dirs();
        // Create the git repo first (init_if_needed runs before remote inspection).
        gix::init(&work_dir).expect("gix::init must succeed");
        // Add a GitLab remote that won't match the github-preference config.
        append_origin_remote(&work_dir, "git@gitlab.com:someone/something.git", &[]);
        (dir, config_path, work_dir)
    }

    fn make_cmd(args: &[&str], config_path: &Path, work_dir: &Path) -> Command {
        let mut cmd = Command::new(env!("CARGO_BIN_EXE_entangle"));
        cmd.arg("init");
        for a in args {
            cmd.arg(a);
        }
        cmd.env("ENTANGLE_CONFIG_PATH", config_path);
        cmd.env("ENTANGLE_SKIP_REMOTE_CHECK", "1");
        cmd.current_dir(work_dir);
        cmd
    }

    #[test]
    fn replace_yes_continues_to_url_preview() {
        let (_dir, config_path, work_dir) = setup_with_gitlab_origin();

        let cmd = make_cmd(&["entangle"], &config_path, &work_dir);
        let mut p = spawn_command(cmd, TIMEOUT_MS).expect("failed to spawn PTY session");

        // Wait for the replace prompt.
        p.exp_string("Replace it with").unwrap();
        // Accept default (Y) by pressing Enter.
        p.send_line("").unwrap();

        // URL preview must appear after the prompt is answered.
        p.exp_string("github.com").unwrap();
        p.exp_eof().unwrap();
    }

    #[test]
    fn replace_no_proceed_yes_shows_warning_and_continues() {
        let (_dir, config_path, work_dir) = setup_with_gitlab_origin();

        let cmd = make_cmd(&["entangle"], &config_path, &work_dir);
        let mut p = spawn_command(cmd, TIMEOUT_MS).expect("failed to spawn PTY session");

        // Decline the replace prompt.
        // rexpect's send() writes to a LineWriter that only auto-flushes on
        // '\n'. After send("n") we must call flush() explicitly so the byte
        // actually reaches the process's stdin before we wait for the next
        // prompt — otherwise 'n' sits in the write buffer indefinitely.
        p.exp_string("Replace it with").unwrap();
        p.send("n").unwrap();
        p.flush().unwrap();

        // Wait for the second prompt, then accept its default (Yes) with
        // send_line("") — the '\n' triggers LineWriter's auto-flush.
        p.exp_string("anyway").unwrap();
        p.send_line("").unwrap();

        // Warning about keeping the existing origin must appear.
        p.exp_string("Note").unwrap();

        p.exp_eof().unwrap();
    }

    /// Read `.git/config` as a raw string for before/after comparison.
    /// Used to verify that a cancelled `entangle init` made no writes.
    fn read_git_config(work_dir: &Path) -> String {
        std::fs::read_to_string(work_dir.join(".git").join("config"))
            .expect("must be able to read .git/config")
    }

    #[test]
    fn ctrl_c_at_replace_prompt_leaves_git_config_unchanged() {
        let (_dir, config_path, work_dir) = setup_with_gitlab_origin();

        // Snapshot the config before running so we can verify nothing changed.
        let config_before = read_git_config(&work_dir);

        let cmd = make_cmd(&["entangle"], &config_path, &work_dir);
        let mut p = spawn_command(cmd, TIMEOUT_MS).expect("failed to spawn PTY session");

        // The replace prompt appears before any .git/config writes.
        // Ctrl+C sends SIGINT, which kills the process — we just wait for EOF.
        p.exp_string("Replace it with").unwrap();
        p.send_control('c').unwrap();
        let _ = p.exp_eof(); // process exits; ignore whether it printed anything

        assert_eq!(
            config_before,
            read_git_config(&work_dir),
            ".git/config must be unchanged after Ctrl+C at the replace prompt"
        );
    }

    #[test]
    fn ctrl_c_at_proceed_prompt_leaves_git_config_unchanged() {
        let (_dir, config_path, work_dir) = setup_with_gitlab_origin();

        // Snapshot the config before running.
        let config_before = read_git_config(&work_dir);

        let cmd = make_cmd(&["entangle"], &config_path, &work_dir);
        let mut p = spawn_command(cmd, TIMEOUT_MS).expect("failed to spawn PTY session");

        // Decline replace, then Ctrl+C at proceed-anyway.
        // Writes only happen after both prompts are answered affirmatively,
        // so the file must be untouched here.
        p.exp_string("Replace it with").unwrap();
        p.send("n").unwrap();
        p.flush().unwrap();

        p.exp_string("anyway").unwrap();
        p.send_control('c').unwrap();
        let _ = p.exp_eof();

        assert_eq!(
            config_before,
            read_git_config(&work_dir),
            ".git/config must be unchanged after Ctrl+C at the proceed-anyway prompt"
        );
    }

    #[test]
    fn replace_no_proceed_no_prints_cancelled_and_exits() {
        let (_dir, config_path, work_dir) = setup_with_gitlab_origin();

        let cmd = make_cmd(&["entangle"], &config_path, &work_dir);
        let mut p = spawn_command(cmd, TIMEOUT_MS).expect("failed to spawn PTY session");

        // Decline both prompts. Each send("n") must be followed by flush()
        // so the byte leaves the LineWriter buffer and reaches the process.
        p.exp_string("Replace it with").unwrap();
        p.send("n").unwrap();
        p.flush().unwrap();

        p.exp_string("anyway").unwrap();
        p.send("n").unwrap();
        p.flush().unwrap();

        // Abort message must appear.
        p.exp_string("cancelled").unwrap();

        p.exp_eof().unwrap();
    }

    #[test]
    fn interactive_init_prompts_for_repo_and_alias() {
        let (_dir, config_path, work_dir) = setup_dirs();

        // Run with NO extra arguments to trigger interactive prompt path
        let cmd = make_cmd(&[], &config_path, &work_dir);
        let mut p = spawn_command(cmd, TIMEOUT_MS).expect("failed to spawn PTY session");

        // 1. Wait for repository name prompt
        p.exp_string("Repository name").unwrap();
        // Send name + Enter
        p.send_line("my-interactive-project").unwrap();

        // 2. Wait for optional alias prompt
        p.exp_string("Alias on mirror forge").unwrap();
        // Send alias + Enter
        p.send_line("my-mirror-alias").unwrap();

        // 3. Confirm completion and output URLs
        p.exp_string("Configuring remotes for 'my-interactive-project'")
            .unwrap();
        p.exp_string("git@github.com:cyrusae/my-interactive-project.git")
            .unwrap();
        p.exp_string("git@tangled.org:atdot.fyi/my-mirror-alias")
            .unwrap();

        p.exp_eof().unwrap();

        // Check git repository exists
        assert!(
            work_dir.join(".git").exists(),
            "git repository must be initialized"
        );

        // Assert git config contains the fetch URL and push URLs
        let git_config = read_git_config(&work_dir);
        assert!(
            git_config.contains("url = git@github.com:cyrusae/my-interactive-project.git"),
            "git config must contain correct fetch URL"
        );
        assert!(
            git_config.contains("pushurl = git@tangled.org:atdot.fyi/my-mirror-alias"),
            "git config must contain Tangled mirror push URL"
        );
        assert!(
            git_config.contains("pushurl = git@github.com:cyrusae/my-interactive-project.git"),
            "git config must contain GitHub origin push URL"
        );
    }
}