Implementation plan #
Step-by-step scaffold. Each step ends with runnable, testable code — no step requires the next one to be useful. Work through them in order; later steps build on earlier ones.
Cross-cutting reminders (apply throughout):
- Most behavior should be testable offline; flag anything that genuinely needs a network call
- Documentation comments and inline comments throughout — don't defer these to a cleanup pass
- Sample data for tests:
cyrusae(GitHub),atdot.fyi(Tangled),entangle(repo) - Windows is a stated target but an untested risk; note any platform-specific assumptions as they appear
Step 1: Project skeleton #
Goal: cargo run -- --help works and lists all four subcommands.
-
cargo new entangle --bin -
Add all dependencies to
Cargo.toml:clap(withderivefeature)dialoguerindicatifowo-colorsserde+serde_jsondirsgix
-
Define
clapcommand/subcommand structure:setup,set,init,shove -
Stub each subcommand handler to print
"not yet implemented"and returnOk(()) -
entanglewith no subcommand prints available commands (not an error) -
Sketch and document
src/module layout — decide now so later steps slot in cleanly. Suggested structure:src/ main.rs # clap wiring, dispatch cli.rs # clap type definitions config.rs # Config struct, load/save validate.rs # sanitization and validation urls.rs # URL construction git.rs # gix wrappers (repo detection, remotes, push) remote.rs # SSH ls-refs, RemoteCheckResult commands/ setup.rs set.rs init.rs shove.rs
Runnable check: cargo build succeeds. cargo run -- --help, cargo run -- init, etc. all produce output without panicking.
Tests: cargo test passes (compilation only at this point).
Step 2: Config — data model and file I/O #
Goal: Can read and write a config file programmatically. No interactive prompts yet.
-
Define
Configstruct withserdederives:pub struct Config { pub github_username: String, pub tangled_username: String, pub origin_preference: OriginPreference, // enum: GitHub | Tangled } -
Implement
Config::path() -> PathBufusingdirs::config_dir() -
Implement
Config::load() -> Result<Config, ConfigError>— reads from{config_dir}/entangle/config.json. Return distinct error variants for:- File not found (no config yet)
- File exists but isn't readable (permissions)
- Valid UTF-8/JSON but empty object
{} - Valid JSON but missing one or more required fields (one error variant per field, so callers can say "run
entangle set gh-user") - Corrupted/unparseable JSON (single catch-all: "config is unreadable, re-run
entangle setup")
-
Implement
Config::save(&self) -> Result<(), ConfigError>— writes to same path, creating{config_dir}/entangle/if needed. Errors for: can't create directory, can't write file.
Tests:
- Unit: serialize a well-formed
Config, deserialize it back, assert equality - Unit: each malformed-config error case (empty file,
{}, missinggithub_username, missingtangled_username, missingorigin_preference, truncated JSON) - Unit:
save()creates the directory if it doesn't exist - Unit:
Config::path()returns a path ending inentangle/config.json(platform-agnostic assertion) - Integration: round-trip —
save()thenload()returns the same struct
📋 Decision baked in: unique errors per missing field (not a single "config invalid"), because missing-field errors are actionable and should tell the user which entangle set command to run. Parse failures get a single catch-all since the file is unreadable regardless of why.
⚠️ Windows note: dirs::config_dir() returns AppData\Roaming on Windows vs. ~/.config on Unix. Document this in config.rs. No action needed yet, but flag it.
Step 3: Input validation module #
Goal: Centralized, fully-tested sanitization and validation for all user input. Pure functions, no I/O, no UI.
- Implement sanitization (applied first, before any validation):
- Lowercase all input
- Strip leading/trailing whitespace
- Remove single and double quotes silently
- Reject any input containing spaces, backticks, or shell metacharacters (
$,;,|,&,>,<,`) — loud error, not silent strip
- Implement
validate_github_username(input: &str) -> Result<String, ValidationError>- Sanitize first, then validate: ≤39 chars, alphanumeric + hyphens only, no leading/trailing/consecutive hyphens
- Implement
validate_tangled_username(input: &str) -> Result<String, ValidationError>- Sanitize first, then validate against ATProto regex:
/^([a-zA-Z0-9]([a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?\.)+[a-zA-Z]([a-zA-Z]{0,61}[a-zA-Z])?$/
- Sanitize first, then validate against ATProto regex:
- Implement
validate_repo_name(input: &str) -> Result<String, ValidationError>- Sanitize first, then validate: 1–100 chars, lowercase alphanumeric + hyphens, no consecutive/leading/trailing hyphens, no periods
Tests (exhaustive boundary cases from TESTING.md):
- GitHub: valid at exactly 39 chars; invalid at 40; consecutive hyphens; leading hyphen; trailing hyphen; mixed case input lowercased before validation
- Tangled:
a.b(minimal valid);atdot.fyi(valid);user.tngl.sh(valid with subdomain); no TLD (invalid); underscore in domain (invalid) - Repo: 1 char (valid); 100 chars (valid); 101 chars (invalid); period (invalid); consecutive hyphens (invalid); leading hyphen (invalid)
- Sanitization:
"CyrusAE"→cyrusae(quotes stripped, then lowercased); backtick → loud error;$VAR→ loud error - Order: confirm sanitization happens before validation (test with
"CyrusAE"— should pass, not fail on uppercase)
📋 Decision point: What is the error message style for validation failures? Decide here so it's consistent across all commands. Suggested form: "'{input}' is not a valid GitHub username: {reason}." — specific, includes the offending input, states the reason.
Step 4: entangle set #
Goal: Can set individual config values from the command line. Config is read, updated, and written correctly.
- Wire
entangle set gh-user <username>→ sanitize/validate → load config (or create empty if missing) → update field → save - Wire
entangle set tngl-user <username>→ same pattern - Wire
entangle set origin <github|tangled>→ validate option → update → save claphandles missing-argument errors (e.g.,entangle set gh-userwith no value); confirm the error messages are informative- Re-setting an already-set value silently overwrites with the same data — no special behavior
Tests:
- Unit: each
setvariant updates the correct field and leaves others unchanged - Unit: invalid GitHub username → validation error, config not written
- Unit: invalid Tangled username → validation error, config not written
- Unit:
set origin bogus→ error, config not written - Unit:
seton a config missing other fields → updates just the one field, doesn't clobber the rest - Integration:
entangle set gh-user cyrusae→ config file on disk has"github_username": "cyrusae"
📋 Decision point: Should entangle set print a confirmation of what it just set? (e.g., "GitHub username set to: cyrusae") — decide on the progress message convention here, since set is the simplest case. Recommendation: yes, always confirm, because silent success is hard to debug.
Step 5: entangle setup #
Goal: Interactive first-time (and repeat) setup works end-to-end.
- Use
dialoguerto prompt for GitHub username, Tangled username, and origin preference in order - Validate each input before accepting; re-prompt with error message on invalid input (don't just reject and exit)
- If a field is already set in the config, use a "currently set to X. Change? [Y/n]" prompt — skip if user says no, prompt for new value if yes
- Write config only after all prompts complete (no partial saves)
- Handle Ctrl+C gracefully:
dialoguerpropagates this as an error; catch it and exit cleanly without writing a partial config
Tests:
- Unit: prompt logic correctly detects pre-existing config fields
- Integration:
entangle setupwith piped input (dialoguer supports non-TTY input for testing) — valid inputs → config written - Integration: invalid input in first prompt → re-prompt, valid on retry → config written
- Integration:
entangle setupwith already-valid config → correct "already set to X" prompts appear - Integration: Ctrl+C mid-setup → no config written (or previously valid config unchanged if one existed)
📋 Decision baked in: On Ctrl+C, do not save partial config. A partial config is worse than no config — the user can always re-run entangle setup.
Step 6: URL construction #
Goal: Given a config and repo name(s), build correct SSH URLs. Pure functions, no network.
- Implement
build_github_url(username: &str, repo: &str) -> String- Output:
git@github.com:{username}/{repo}.git
- Output:
- Implement
build_tangled_url(username: &str, repo: &str) -> String- Output:
git@tangled.org:{username}/{repo}(no.git)
- Output:
- Implement
resolve_urls(config: &Config, repo: &str, alias: Option<&str>) -> (String, String)- Returns
(origin_url, mirror_url)based onorigin_preference - If
aliasis provided, it becomes the repo name on the non-origin forge - If no alias, same repo name is used for both
- Returns
Tests:
- Unit:
build_github_url("cyrusae", "entangle")→"git@github.com:cyrusae/entangle.git" - Unit:
build_tangled_url("atdot.fyi", "entangle")→"git@tangled.org:atdot.fyi/entangle" - Unit:
resolve_urlswith GitHub as origin → GitHub URL is first (origin), Tangled is second (mirror) - Unit:
resolve_urlswith Tangled as origin → Tangled URL is first, GitHub is second - Unit:
resolve_urlswith alias present → alias used for mirror forge, original name used for origin forge - Unit:
resolve_urlswith no alias → same name on both forges
Step 7: Remote validation #
Goal: Can check whether an SSH remote actually exists, with three distinct error paths.
- Implement
RemoteCheckResultenum:Ok,NotFound,AuthFailure,NetworkError(String) - Implement
check_remote(url: &str) -> RemoteCheckResultusinggixSSH ls-refs- Map
gixerror types to the three named cases; document the mapping in comments - Include timeout handling — network errors should not hang indefinitely
- Map
- Implement
validate_remotes(origin_url: &str, mirror_url: &str) -> Result<(), RemoteError>- Checks origin first, then mirror
- On
NetworkError: surfaces the offline override prompt ("Couldn't reach remote — accept anyway? [y/N]") - On
NotFoundorAuthFailure: hard stop, distinct error messages
Tests:
- Unit (offline): test each
RemoteCheckResultvariant by injecting mock transport or pointing at localhost - Unit:
validate_remotesfails on origin before trying mirror - Unit: network error triggers override prompt logic (test the logic path, not the actual prompt)
- Integration (online, skippable with a feature flag or
#[ignore]): real ls-refs againstgithub.com/cyrusae/entangle - Integration: non-routable address →
NetworkErrorpath (e.g.,git@192.0.2.1:user/repo)
📋 Decision point: How do gix errors surface in user-facing messages? Options: (a) expose gix error text directly, (b) always map to one of the three named cases with entangle-authored messages. Recommendation: (b) — map everything to named cases, include gix error detail only in a debug/verbose mode. Decide verbosity levels here if not already settled.
Step 8: entangle init — git detection and local setup #
Goal: entangle init can validate config, collect repo name(s), detect git status of the current directory, and initialize if needed. No remote logic yet.
- Check config exists and is valid → refer to
entangle setupif not (step 1 of init flow) - Parse CLI args or prompt interactively for repo name and optional alias (steps 2–3)
- Validate repo name(s) using the validation module (step 4)
- Check if the current directory is a git repo using
gix- If not: initialize it with
gix, print"Folder is not a git repository, initializing..." - Check for
.gitignoreandREADME.md; print suggestions for any that are missing
- If not: initialize it with
- Build URLs (step 5) — needed later but construct them here so they're available for the remote steps
Tests:
- Unit: git-repo detection on a non-repo temp directory
- Unit: git-repo detection on an existing repo
- Unit:
.gitignoredetection (present / absent) - Unit:
README.mddetection (present / absent) - Integration:
entangle initin a fresh temp directory → git repo initialized, appropriate messages printed - Integration (idempotency): run
entangle inittwice in the same fresh directory → second run sees the initialized repo, does not re-initialize
Step 9: entangle init — remote inspection and overwrite prompt #
Goal: entangle init reads existing remotes and handles the overwrite prompt flow correctly.
- Read existing remotes from the repo using
gix - Early exit with a success message if both GitHub and Tangled push URLs are already present (step 8.1)
- Detect if
originexists but doesn't match the generated origin URL (step 8.2) - Implement the two-level overwrite prompt using
dialoguer:An origin remote already exists: git@gitlab.com:someone/something.git Replace it with git@github.com:cyrusae/entangle.git? [Y/n] → No → Add push URLs to existing origin anyway? [Y/n] → No → abort (clean exit, no changes made) - Output for the "proceed anyway" path should explicitly state what state the user is left in
Tests:
- Unit: remote inspection with no remotes configured
- Unit: remote inspection with origin matching expected URL
- Unit: remote inspection with origin not matching (GitLab URL, etc.)
- Unit: remote inspection with both GitHub + Tangled already set → early exit
- Unit: remote inspection with remotes but none named
origin - Integration: overwrite prompt, replace=yes → continues normally
- Integration: overwrite prompt, replace=no, proceed=yes → continues with warning message
- Integration: overwrite prompt, replace=no, proceed=no → clean abort, no changes
📋 Decision point: What exactly does the "proceed anyway" success output say? Draft it here. Suggested: "Heads up: origin fetch URL is git@gitlab.com:…, not a GitHub or Tangled remote. Push URLs have been added — your pushes will go to both forges, but pulls will come from the existing origin." Finalize wording before moving to step 10.
Step 10: entangle init — remote configuration and confirmation #
Goal: entangle init wires up push remotes in the correct order and confirms the final state.
- Add non-default-forge push URL via
gix(e.g., Tangled if GitHub is origin) - Add default-forge push URL via
gix(order matters — default is last) - Print the final
git remote -v-equivalent output so the user can verify - Print the
entangle shovesuggestion
Tests:
- Unit: push URL order is correct (non-default first, default last)
- Unit:
gixremote state after the operation matches expected (origin fetch URL + two push URLs) - Integration: full end-to-end
entangle initin a fresh repo with no existing remotes →git remote -voutput matches expected format - Integration (idempotency):
entangle initon a repo that already has both push URLs configured → early exit at step 9, no writes - Integration:
entangle initon a repo with a matching origin but no push URLs → adds push URLs correctly
Step 11: entangle shove #
Goal: entangle shove pushes all branches and tags to both remotes.
- Verify the current directory is a git repo; error if not
- Run
git push origin --allthengit push origin --tagsviagix- Since
originhas two push URLs configured, one command reaches both forges
- Since
- Informative error messages if either push fails (surface the underlying error)
entangle shovewith unexpected arguments → error with explanation (clap handles this)
Tests:
- Integration:
entangle shovein a directory that isn't a git repo → informative error - Integration:
entangle shovein a repo with no commits → informative error (nothing to push) - Integration:
entangle shovewith unexpected arguments → error with explanation - Integration (online,
#[ignore]):entangle shovein a configured repo → both remotes receive the push
Step 12: Output polish #
Goal: Consistent, readable terminal output across all commands. No new functionality — audit and harmonize what exists.
- Audit all output strings across all commands; write out the style guide that emerges:
- Progress messages (what's happening now)
- Success messages (what just happened)
- Warning messages (something's off but not fatal)
- Error messages (fatal; always suggest what to do next)
- Apply
owo-colorsconsistently across all four categories - Add
indicatifspinners to: remote validation (check_remotecalls), push operations (entangle shove) - Ensure no command exits silently on success — always confirm what happened
Tests:
- Visual review of all command outputs in a real terminal
- Integration: no command produces output that references internal types or
gixerror strings (unless in a future debug/verbose mode)
Step 13: Hardening and edge cases #
Goal: Systematic coverage of the remaining TESTING.md cases not addressed in earlier steps.
Work through each section of TESTING.md and confirm coverage:
- Config and file I/O: permission-denied on config directory; unreadable config file; all partial-config variants
- Input boundary cases: all max/min-length inputs;
a.bTangled address; shell metacharacters - Argument parsing:
entangle initwith 3+ args;entangle setsubcommands missing their values;entanglealone - Config state weirdness: config with one field missing; Ctrl+C mid-
setupon existing config - Git state mismatches: GitLab origin; remotes present but none named
origin; multiple unrecognized remotes - Case handling: confirm sanitization-before-validation end-to-end (not just unit tested)
- Windows: test config path construction; test that no hardcoded
/separators sneak in
For each case, either confirm it's already covered by an earlier step's tests or add a new test here.
📋 Final documentation pass: Once hardening is done, update DESIGN.md and TESTING.md to reflect anything discovered during implementation that changed the approach. Note any cases deferred to post-MVP.
Step 14: Release packaging and CI/CD refinement #
Goal: Ship a proper v1.0.0 to crates.io, set up automated release tooling, and harden the CI pipeline for ongoing maintenance. This step happens after the codebase is feature-complete and stabilized on a main branch, once development has moved to a dedicated branch workflow.
Branch strategy (prerequisite) #
Before this step, establish the long-term branch model:
main— stable, always releasable; protected branchdev(ornext) — integration branch; PRs target this first- Feature branches off
dev; merge todev; promotedev→mainfor releases
CI should run on both main and dev (and all PRs targeting either).
Cargo.toml metadata #
Fill in the fields required by crates.io before first publish:
[package]
description = "Easy setup for mirroring GitHub repos to Tangled.org"
license = "MIT" # or "MIT OR Apache-2.0" — decide before publishing
repository = "https://github.com/cyrusae/entangle"
homepage = "https://github.com/cyrusae/entangle"
documentation = "https://docs.rs/entangle"
keywords = ["git", "tangled", "mirror", "forge", "atproto"]
categories = ["command-line-utilities", "development-tools"]
readme = "README.md"
Run cargo publish --dry-run to catch any missing fields before the real publish.
release-plz #
release-plz automates: version bumps from Conventional Commits, CHANGELOG.md updates, GitHub releases, and crates.io publishing — all triggered by merging to main.
Setup steps:
- Add the
release-plzGitHub Actions workflow (see release-plz docs for the standard snippet) - Generate a crates.io API token; add it as
CARGO_REGISTRY_TOKENin repo secrets - Grant the workflow permission to push tags and create GitHub releases (
contents: write,pull-requests: write) - Configure
.release-plz.tomlif non-default behavior is needed (e.g., to control which workspace members are published)
Because chainlink already writes CHANGELOG.md entries per-issue, the changelog release-plz generates will build on that foundation — review the format expectations and align if needed.
CI/CD pipeline refinements #
Additions to the GitHub Actions workflow once release infra is in place:
cargo deny: license compatibility and known-vulnerability audit on every PR (cargo-denyaction,deny.toml)- Coverage reporting:
cargo llvm-cov→ upload to Codecov or similar; add a badge to README - Minimum Supported Rust Version (MSRV): decide on a floor (e.g., stable-2 releases); add an
msrvjob that builds on that version - Release job: triggered only on version tags (
v*); runscargo publishwithCARGO_REGISTRY_TOKEN cargo doc: build and publish docs to GitHub Pages on everymainpush
Pre-publish checklist #
Tests: No new unit/integration tests in this step — the test suite already exists. The "test" here is that CI is fully green on the release commit and cargo publish --dry-run reports no errors.