diff --git a/docs/quickstart.md b/docs/quickstart.md index 7b3155a..87a7e22 100644 --- a/docs/quickstart.md +++ b/docs/quickstart.md @@ -281,9 +281,24 @@ Knit infers each repo's base from Git's remote-default metadata when available, See [reference.md](reference.md) for the complete behavior of every command and subsystem. -For an existing GitHub fork, configure ordinary Git remotes and run -`knit publish create --source-remote origin --target-remote upstream`. -Knit pushes the fork and opens the review on upstream; it does not create the -fork. A split origin push URL is detected automatically. See -[the contribution publishing reference](reference.md) for portable artifacts and -credential configuration. +## Contribute to an upstream project + +```sh +mkdir ~/oss && cd ~/oss +knit init oss upstream-org/library upstream-org/website +knit bundle "Describe the change" +# Edit files in the bundle worktrees, then commit. +knit commit --all -m "Describe the change" +knit push --set-upstream # branches to upstream or your forks, no PRs +knit publish create # PRs on upstream, when you decide +``` + +Knit asks GitHub whether you can push to each repository. Repositories you can +push to are not forked. For the others, Knit creates or reuses your fork, keeping +origin fetching from upstream and pushing to the fork. If permissions cannot be +determined, sign in with `knit auth` or choose `--fork` or `--no-fork`. +Add another repository with `knit project add library `. For repositories +already in the project, run `knit fork [repo-id...]`; `--dry-run` shows what it +would do. The project itself keeps only upstream URLs, so anyone who later +clones it with `knit clone` gets the same check for their own checkouts. See +[the reference](reference.md#create-contribution-forks). diff --git a/docs/reference.md b/docs/reference.md index fe492e4..b18e5f2 100644 --- a/docs/reference.md +++ b/docs/reference.md @@ -48,11 +48,12 @@ User-global Knit config lives outside the workspace at `$KNIT_HOME/config.json`, ## Commands ```sh -knit clone https://// [target] [--token ] [--active-bundle ] [--view |--repo ...] [--no-worktree] [--json] -knit clone / [target] [--remote ] [--url ] [--token ] [--active-bundle ] [--view |--repo ...] [--no-worktree] [--json] -knit init [--agents] +knit clone https://// [target] [--token ] [--active-bundle ] [--view |--repo ...] [--no-worktree] [--fork | --no-fork] [--owner ] [--json] +knit clone / [target] [--remote ] [--url ] [--token ] [--active-bundle ] [--view |--repo ...] [--no-worktree] [--fork | --no-fork] [--owner ] [--json] +knit init [...] [--fork | --no-fork] [--owner ] [--agents] knit agents [project] # refresh workspace + project AGENTS.md sections -knit project add [--base ] [--observe] [--agents] +knit project add [--base ] [--observe] [--agents] [--fork | --no-fork] [--owner ] +knit fork [...] [--owner ] [--force] [--dry-run] [--json] knit project set-base [--project ] knit project set-draft [true|false] [--project ] knit project push [name] [--remote ] [--prune] @@ -92,7 +93,7 @@ knit bundle "" [--project <name>] [--repo <repo-id>]... [--all-repos] [-- knit bundle add <repo-path-or-project-repo-id>... [--base <branch>] [--offline|--from-local-base] [--in-place] [--no-worktree] knit bundle remove <repo-id>... [--keep-worktree|--delete-branch] [--force] knit bundle worktree -knit bundle pull <slug> [--json] +knit bundle pull <slug> [--fork | --no-fork] [--owner <org>] [--json] knit bundle apply-view <name> [--keep-worktree|--delete-branch] [--force] knit bundle list [--all] [--archived] [--deleted] [--json] knit bundle archive <bundle> [--reason <reason>] [--keep-worktrees] [--force] @@ -111,7 +112,7 @@ knit status [--json] knit workspace status knit diff [--published] [--stat] [repo-id-or-path...] knit fetch [--mode all|git|knit] [--remote <name>] [repo-id-or-path...] -knit pull [--base] [--current] [--bundles] [--all] [--rebase] [--force] [--feature] [--remote <name>] [--no-remote] [--merge] [repo-id-or-path...] +knit pull [--base] [--current] [--bundles] [--all] [--rebase] [--force] [--feature] [--remote <name>] [--no-remote] [--merge] [--fork | --no-fork] [--owner <org>] [repo-id-or-path...] knit push [--all] [--set-upstream] [--remote <name>]... [--no-remote] [--no-history] [repo-id-or-path...] knit run <project-command> [--repo <repo>]... [--all] knit run [--repo <repo>] [--all] -- <command> [args...] @@ -160,7 +161,7 @@ knit doctor knit migrate [--check] knit sync # record git commits made outside Knit (local reconcile) knit sync push [--bundles] [--history] [--views] [--architecture] [--kg] [--all] [--remote <name>]... -knit sync pull [--bundles] [--history] [--views] [--architecture] [--kg] [--all] [--repos] [--remote <name>]... +knit sync pull [--bundles] [--history] [--views] [--architecture] [--kg] [--all] [--repos] [--fork | --no-fork] [--owner <org>] [--remote <name>]... knit history [list] [-n <count>] [--repo <repo>] [--bundle <bundle>] [--kind <kind>]... [--project <project>] knit history refresh [--rebuild] [--project <project>] knit related [--repo <repo>] [--project <project>] [--pull] [--remote <name>] [--limit <count>] [--commit-limit <count>] <path>... @@ -956,6 +957,83 @@ Commits Knit creates (`commit` and `squash`) use the Git-config identity. Inject When the configured sync remote reports a canonical web URL for the bundle, the managed section starts with `## Knit Bundle`, followed immediately by a generic `[View bundle](URL)` link. This section moves to the top of the PR description, ahead of any author prose. The CLI never derives that URL itself: it records the server-reported value under `syncTargets[].webUrl` (retained across pushes to a server that stops reporting it, dropped when the hosted identity changes) and omits the link entirely when no usable URL is known. Authors' text outside the managed block is preserved until its body file changes; repeated syncs replace the link cleanly without duplicates. +### Create contribution forks + +```sh +knit init contributions upstream-org/library web=https://github.com/upstream-org/website.git +knit project add tools https://github.com/upstream-org/tools +knit project add website https://github.com/upstream-org/website --fork --owner contributor-org --observe +knit fork library # an already registered source checkout +knit fork --dry-run # preview every project repository +knit fork --json # machine-readable per-repository results +``` + +`knit init <name> [<repo>...]` creates a project and clones its repositories into +the workspace. Each reference is `owner/name` (expanded to +`https://github.com/owner/name.git`), a clone URL, or `id=<owner/name or URL>`. +Without `id=`, the repository name becomes its ID; IDs are slugified just like +other project repository IDs. All references and duplicate IDs are checked +before creating anything. Clones run in parallel, then repositories are added +and their permissions checked in argument order. A failure does not stop the +remaining repositories; the command exits nonzero. Without repository arguments, +`knit init <name>` creates the same empty project as before. + +`knit project add <repo-id> <path-or-url>` accepts a local checkout or a remote +URL. A URL is cloned into `<workspace-root>/<repo-id>` with `--filter=blob:none`; +an existing directory there is reused only when it is a clone of the same +repository. `init` uses the same clone behavior. + +Both commands accept these contribution modes: + +- Automatic (the default for URL inputs): Knit asks GitHub whether you can push + to the upstream repository. If you can, it keeps the direct clone and prints + `<id>: direct (you can push)`. Otherwise it creates or reuses your fork and + prints `<id>: via fork <owner>/<name>`. +- `--fork`: create or reuse a fork even when you can push upstream. +- `--no-fork`: keep the direct clone without making forge API calls. Local paths + passed to `project add` use this mode by default; `--fork` also works on them. + +`--fork` and `--no-fork` conflict. `--owner <org>` selects the fork owner in +automatic or forced fork mode and cannot be combined with `--no-fork`. +Automatic permission checks currently support `github.com` only. If credentials +or permission information are unavailable, or the forge is unsupported, that +repository fails with guidance to sign in using `knit auth` or pass `--fork` or +`--no-fork`. Its clone remains registered in the project. + +`knit fork` works on the active project's source checkouts, every repository +when no IDs are given. It creates your GitHub fork, or reuses the one you +already have (renamed forks included), waits until GitHub has created it, and +checks that its parent is the upstream repository. It then sets origin's push +URL to the fork, over the same transport (HTTPS or SSH) as origin's fetch URL. +The fetch URL stays upstream, so bases and updates keep coming from upstream, +while `knit push` and `knit publish create` send branches to the fork and open +reviews upstream. Linked bundle worktrees share this configuration. + +Forks are public, so Knit names the owner before creating the first one. +`--owner <org>` forks into an organization. An existing push URL for the same +fork is kept; a different one is replaced only with `--force`, and only when +it is set in the repository's own Git config. Several push URLs are refused. +`--dry-run` reads from GitHub but creates nothing and changes no Git config. +A repository that fails does not stop the others; the exit status is then +nonzero. Fork creation supports `github.com` only for now. Knit never pushes +branches or opens pull requests as part of forking. + +The project records only upstream URLs; a fork lives in one person's checkout +as origin's push URL. So the same choice is made again whenever Knit clones a +project repository for you: `knit clone`, `knit sync pull --repos`, `knit pull`, +`knit bundle pull`, and `knit handoff in`. Each accepts `--fork`, `--no-fork`, +and `--owner <org>` with the meanings above. Automatic mode forks a fresh +GitHub checkout you cannot push to and prints `<id>: via fork <owner>/<name>`. +When Knit cannot tell, the clone still succeeds and Knit says so, naming +`knit fork <id>` for the case where you cannot push. Checkouts that already +exist are never changed, and other forges keep pushing upstream. +`knit clone --json` reports the outcome per repository as +`"push": "direct" | "fork" | "unknown"`, with `"fork"` naming the fork. + +Because nothing in the project points at a fork, a project a contributor set up +can be handed over as it is: the new owner's members clone it and push +directly, while the contributor's checkouts keep pushing to their forks. + For an existing GitHub fork, select the Git remotes for one repository: ```sh @@ -966,7 +1044,7 @@ The source uses the remote's push URL; the target uses its fetch URL. A split `origin` push URL, `branch.<branch>.pushRemote`, or `remote.pushDefault` is also discovered automatically against origin's fetch URL. Knit records portable `sourceRemote`/`targetRemote` URLs and reuses them on subsequent commands; it -neither creates forks nor rewrites remotes. `--source-remote` and +never rewrites origin's fetch URL; `knit fork` sets only its push URL. `--source-remote` and `--target-remote` require one selected repository and are unavailable in artifact mode, where the URLs must already be recorded. `--remote` still selects hosted bundle sync destinations, and `--target` still selects a base **branch**. diff --git a/src/cli.rs b/src/cli.rs index 9854b34..d33d142 100644 --- a/src/cli.rs +++ b/src/cli.rs @@ -46,6 +46,20 @@ pub enum HistoryGroupArg { Bundle, } +/// How fresh checkouts of project repositories choose where to push. +#[derive(Args, Clone, Debug, Default)] +pub struct CheckoutForkArgs { + /// Push freshly cloned repositories to your fork, even where you can push upstream. + #[arg(long, conflicts_with = "no_fork")] + pub fork: bool, + /// Push freshly cloned repositories upstream without checking push access. + #[arg(long)] + pub no_fork: bool, + /// Organization to fork into; defaults to your own account. + #[arg(long, conflicts_with = "no_fork")] + pub owner: Option<String>, +} + #[derive(Args, Clone, Debug, Default)] pub struct LogArgs { /// Boolean history expression: NOT > AND > OR; adjacency means AND. Fields: repo, view, bundle, since, until. @@ -132,6 +146,23 @@ pub struct LogArgs { #[derive(Subcommand)] pub enum Commands { + /// Create or reuse your GitHub fork of project repos and push their branches there. + Fork { + /// Repos to fork; every project repo when omitted. + repo_ids: Vec<String>, + /// Organization to fork into; defaults to your own account. + #[arg(long)] + owner: Option<String>, + /// Show what would happen without creating forks or changing Git config. + #[arg(long)] + dry_run: bool, + /// Print the results as JSON. + #[arg(long)] + json: bool, + /// Replace a different push URL that origin already has. + #[arg(long)] + force: bool, + }, /// Set up credentials: bare `knit auth` is a guided menu for both token kinds — personal forge tokens (GitHub/GitLab/Bitbucket/Forgejo) and the separate hosted sync-service token (`r`); `knit auth --project NAME` wires one project's forge tokens. Auth { /// Scope the wizard to one project: use the default tokens or give the project its own. @@ -149,6 +180,17 @@ pub enum Commands { Init { /// Project name. name: String, + /// Repository references: owner/name, clone URL, or id=reference. + repos: Vec<String>, + /// Always create or reuse a fork. + #[arg(long, conflicts_with = "no_fork")] + fork: bool, + /// Use repositories directly without checking push access. + #[arg(long)] + no_fork: bool, + /// Organization to fork into; defaults to your own account. + #[arg(long, conflicts_with = "no_fork")] + owner: Option<String>, /// Write or refresh project-specific AGENTS.md guidance. #[arg(long)] agents: bool, @@ -233,6 +275,8 @@ pub enum Commands { /// lines move to stderr. #[arg(long)] json: bool, + #[command(flatten)] + forks: CheckoutForkArgs, }, /// Stage file changes inside tracked checkouts, like git add. Add { @@ -395,6 +439,8 @@ pub enum Commands { /// have diverged, instead of keeping local and warning. #[arg(long)] merge: bool, + #[command(flatten)] + forks: CheckoutForkArgs, }, /// Push tracked feature branches. Push { @@ -732,6 +778,8 @@ pub enum SyncCommand { /// Named sync remote(s). Repeat for several. Defaults to configured sync remotes. #[arg(long, value_name = "REMOTE")] remote: Vec<String>, + #[command(flatten)] + forks: CheckoutForkArgs, }, } @@ -814,6 +862,8 @@ pub enum BundleCommand { /// move to stderr. #[arg(long)] json: bool, + #[command(flatten)] + forks: CheckoutForkArgs, }, /// Add repos or project repo ids to the current bundle. Add { @@ -992,8 +1042,18 @@ pub enum ProjectCommand { Add { /// Stable repo id inside the project. repo_id: String, - /// Path to a local git repository. + /// Path to a local git repository, or a remote URL to clone. + #[arg(value_name = "PATH_OR_URL")] repo_path: PathBuf, + /// Fork the repository and push its branches to the fork (`knit fork`). + #[arg(long, conflicts_with = "no_fork")] + fork: bool, + /// Use the repository directly without checking push access. + #[arg(long)] + no_fork: bool, + /// Organization to fork into; defaults to your own account. + #[arg(long, conflicts_with = "no_fork")] + owner: Option<String>, /// Override the inferred base branch. #[arg(long)] base: Option<String>, @@ -1811,6 +1871,8 @@ pub enum HandoffCommand { force: bool, #[arg(long)] json: bool, + #[command(flatten)] + forks: CheckoutForkArgs, }, /// Show the bundle's advisory handoff location. Status { diff --git a/src/commands/fork.rs b/src/commands/fork.rs new file mode 100644 index 0000000..38e93fc --- /dev/null +++ b/src/commands/fork.rs @@ -0,0 +1,407 @@ +use crate::contribution::same_repository; +use crate::git::{git_output, git_output_optional}; +use crate::model::{KnitProject, ProjectRepoEntry}; +use crate::providers::{github::GitHub, Forge, ForkRepository, PrTarget}; +use crate::store::{find_knit_root, load_config, project_path, read_json}; +use anyhow::{bail, Context, Result}; +use serde_json::{json, Value}; +use std::collections::BTreeMap; +use std::path::Path; +use std::sync::{Mutex, OnceLock}; + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum ForkMode { + Auto, + Always, + Never, +} + +impl ForkMode { + pub fn from_flags(fork: bool, no_fork: bool) -> Self { + if fork { + Self::Always + } else if no_fork { + Self::Never + } else { + Self::Auto + } + } +} + +enum PushAccess { + Yes, + No, + Unknown(anyhow::Error), +} + +fn push_access(cwd: &Path, id: &str) -> Result<(String, PushAccess)> { + let upstream = git_output(cwd, ["config", "--get", "remote.origin.url"])?; + let upstream = upstream.trim(); + let name = crate::auth::remote_target(upstream) + .map(|(_, name)| name) + .unwrap_or_else(|_| id.to_owned()); + let Ok(full_name) = crate::contribution::github_name(upstream) else { + return Ok(( + name, + PushAccess::Unknown(anyhow::anyhow!("not a github.com repository")), + )); + }; + let mut target = PrTarget::explicit(cwd, &full_name); + target.repo_remote = Some(upstream.to_owned()); + let access = match GitHub.can_push(&target) { + Ok(Some(true)) => PushAccess::Yes, + Ok(Some(false)) => PushAccess::No, + Ok(None) => PushAccess::Unknown(anyhow::anyhow!("GitHub did not report push permission")), + Err(error) if missing_credentials(&error) => PushAccess::Unknown(error), + Err(error) => return Err(error), + }; + Ok((name, access)) +} + +pub fn apply_mode( + repo: &ProjectRepoEntry, + mode: ForkMode, + owner: Option<&str>, + announced: &mut bool, +) -> Result<()> { + if mode == ForkMode::Never { + println!("{}: direct (--no-fork)", repo.id); + return Ok(()); + } + let cwd = Path::new(&repo.path); + if mode == ForkMode::Auto { + let (name, access) = push_access(cwd, &repo.id)?; + let unknown = || { + anyhow::anyhow!( + "Knit can't tell whether you can push to {name}; sign in with `knit auth` or pass --fork or --no-fork" + ) + }; + match access { + PushAccess::Yes => { + println!("{}: direct (you can push)", repo.id); + return Ok(()); + } + PushAccess::No => {} + PushAccess::Unknown(error) if missing_credentials(&error) => { + return Err(error.context(unknown())) + } + PushAccess::Unknown(_) => return Err(unknown()), + } + } + let result = fork_one(&repo.id, cwd, owner, false, false, announced)?; + println!( + "{}: via fork {}", + repo.id, + result["fork"].as_str().unwrap_or_default() + ); + Ok(()) +} + +/// Where a fresh checkout of a project member pushes. +#[derive(Clone, Debug, PartialEq, Eq)] +pub enum CheckoutPush { + Direct, + Fork(String), + Unknown, +} + +impl CheckoutPush { + pub fn status(&self) -> &'static str { + match self { + Self::Direct => "direct", + Self::Fork(_) => "fork", + Self::Unknown => "unknown", + } + } + + pub fn fork(&self) -> Option<&str> { + match self { + Self::Fork(name) => Some(name), + _ => None, + } + } +} + +/// The fork-or-direct decision for members cloned from a sync remote. Every +/// person decides for themselves at checkout, so the shared project never +/// records a fork. +pub struct CheckoutForks { + mode: ForkMode, + owner: Option<String>, + announced: Mutex<bool>, + outcomes: Mutex<BTreeMap<String, CheckoutPush>>, +} + +static CHECKOUT_FORKS: OnceLock<CheckoutForks> = OnceLock::new(); + +/// Set once from the command line, before any repository is cloned. +pub fn set_checkout_forks(mode: ForkMode, owner: Option<String>) { + let _ = CHECKOUT_FORKS.set(CheckoutForks::new(mode, owner)); +} + +pub fn checkout_forks() -> &'static CheckoutForks { + CHECKOUT_FORKS.get_or_init(|| CheckoutForks::new(ForkMode::Auto, None)) +} + +impl CheckoutForks { + fn new(mode: ForkMode, owner: Option<String>) -> Self { + Self { + mode, + owner, + announced: Mutex::new(false), + outcomes: Mutex::new(BTreeMap::new()), + } + } + + /// Unlike `knit init`, an unknown answer does not fail: the clone already + /// succeeded and its owner may only want to read. Only GitHub repositories + /// can be forked, so other hosts keep pushing upstream. + pub fn apply(&self, id: &str, cwd: &Path) -> Result<()> { + let upstream = git_output(cwd, ["config", "--get", "remote.origin.url"])?; + let upstream = upstream.trim(); + if self.mode != ForkMode::Always && crate::contribution::github_name(upstream).is_err() { + return Ok(()); + } + let push = match self.mode { + ForkMode::Never => CheckoutPush::Direct, + ForkMode::Auto => match push_access(cwd, id) { + Ok((_, PushAccess::Yes)) => CheckoutPush::Direct, + Ok((_, PushAccess::No)) => self.fork(id, cwd)?, + Ok((_, PushAccess::Unknown(_))) | Err(_) => { + let name = crate::auth::remote_target(upstream) + .map(|(_, name)| name) + .unwrap_or_else(|_| id.to_owned()); + crate::human!( + "{id}: Knit can't tell whether you can push to {name}. If you can't, run `knit fork {id}`." + ); + CheckoutPush::Unknown + } + }, + ForkMode::Always => self.fork(id, cwd)?, + }; + self.outcomes + .lock() + .expect("checkout outcomes lock poisoned") + .insert(id.to_owned(), push); + Ok(()) + } + + fn fork(&self, id: &str, cwd: &Path) -> Result<CheckoutPush> { + let mut announced = self.announced.lock().expect("fork notice lock poisoned"); + let result = fork_one(id, cwd, self.owner.as_deref(), false, false, &mut announced)?; + let name = result["fork"].as_str().unwrap_or_default().to_owned(); + crate::human!("{id}: via fork {name}"); + Ok(CheckoutPush::Fork(name)) + } + + pub fn outcome(&self, id: &str) -> Option<CheckoutPush> { + self.outcomes + .lock() + .expect("checkout outcomes lock poisoned") + .get(id) + .cloned() + } +} + +fn missing_credentials(error: &anyhow::Error) -> bool { + let message = format!("{error:#}").to_ascii_lowercase(); + crate::providers::is_likely_host_auth_failure(error) + || message.contains("gh auth login") + || message.contains("not logged") + || message.contains("requires gh_token or github_token") + || error + .downcast_ref::<std::io::Error>() + .is_some_and(|error| error.kind() == std::io::ErrorKind::NotFound) +} + +pub fn fork_repos( + ids: &[String], + owner: Option<&str>, + dry_run: bool, + json_output: bool, + force: bool, +) -> Result<()> { + if json_output { + crate::output::route_human_lines_to_stderr(); + } + let root = find_knit_root(&std::env::current_dir()?).context("No Knit workspace found")?; + let _credentials = crate::auth::activate_clone_root(root.clone()); + let project_id = load_config(&root)? + .active_project + .context("No active Knit project")?; + let project: KnitProject = read_json(&project_path(&root, &project_id))?; + let ids: Vec<String> = if ids.is_empty() { + project.repos.iter().map(|repo| repo.id.clone()).collect() + } else { + ids.to_vec() + }; + let mut results = Vec::new(); + let mut failed = false; + let mut announced = false; + for id in ids { + let result = project + .repos + .iter() + .find(|repo| repo.id == id) + .with_context(|| format!("unknown project repo `{id}`")) + .and_then(|repo| { + fork_one( + &repo.id, + Path::new(&repo.path), + owner, + dry_run, + force, + &mut announced, + ) + }); + match result { + Ok(value) => { + if !json_output { + println!( + "{id}: {} → {} ({}), pushes {} to the fork", + value["upstream"].as_str().unwrap_or_default(), + value["fork"].as_str().unwrap_or_default(), + value["status"].as_str().unwrap_or_default(), + if dry_run { "would go" } else { "go" } + ); + } + results.push(value); + } + Err(error) => { + failed = true; + if !json_output { + eprintln!("{id}: {error:#}"); + } + results + .push(json!({"repoId": id, "status": "error", "error": format!("{error:#}")})); + } + } + } + if json_output { + println!( + "{}", + serde_json::to_string_pretty(&json!({"dryRun": dry_run, "repos": results}))? + ); + } + if failed { + bail!("one or more repositories could not be forked"); + } + Ok(()) +} + +fn fork_one( + id: &str, + cwd: &Path, + owner: Option<&str>, + dry_run: bool, + force: bool, + announced: &mut bool, +) -> Result<Value> { + let upstream = git_output(cwd, ["config", "--get", "remote.origin.url"])? + .trim() + .to_owned(); + let full_name = crate::contribution::github_name(&upstream) + .map_err(|_| anyhow::anyhow!("{upstream}: Knit can only create forks on github.com"))?; + let pushurls = git_output_optional(cwd, ["config", "--get-all", "remote.origin.pushurl"])? + .unwrap_or_default(); + let current = match pushurls.lines().collect::<Vec<_>>()[..] { + [] => None, + [url] => Some(url.to_owned()), + _ => bail!("origin has several push URLs; keep one and run again"), + }; + let mut target = PrTarget::explicit(cwd, &full_name); + target.repo_remote = Some(upstream.clone()); + let ssh = !upstream.starts_with("https://") && !upstream.starts_with("http://"); + let push_url = |fork: &ForkRepository| match ssh { + true => fork.ssh_url.clone(), + false => fork.clone_url.clone(), + }; + + let planned = GitHub.ensure_fork(&target, owner, true)?; + check_current(cwd, current.as_deref(), &push_url(&planned), force)?; + let fork = if dry_run { + planned + } else { + if !*announced { + let login = planned.full_name.split('/').next().unwrap_or_default(); + eprintln!("Forks are public repositories under {login}."); + *announced = true; + } + GitHub.ensure_fork(&target, owner, false)? + }; + let url = push_url(&fork); + let unchanged = current + .as_deref() + .is_some_and(|current| same_repository(current, &url).unwrap_or(false)); + if !dry_run && !unchanged { + check_current(cwd, current.as_deref(), &url, force)?; + git_output( + cwd, + [ + "config", + "--local", + "--replace-all", + "remote.origin.pushurl", + &url, + ], + )?; + } + let status = match (dry_run, fork.existed) { + (true, false) => "would create", + (false, false) => "forked", + (_, true) => "exists", + }; + Ok(json!({ + "repoId": id, + "upstream": full_name, + "fork": fork.full_name, + "status": status, + "pushUrl": current.filter(|_| unchanged).unwrap_or(url), + })) +} + +fn check_current(cwd: &Path, current: Option<&str>, intended: &str, force: bool) -> Result<()> { + let Some(current) = current else { + return Ok(()); + }; + if same_repository(current, intended).unwrap_or(false) { + return Ok(()); + } + let shown = crate::commands::push::sanitized_push_url(current); + if !force { + bail!("origin already pushes to `{shown}`; use --force to point it at `{intended}`"); + } + // Replacing the local value would leave a global or included one in place: two push URLs. + let local = git_output_optional( + cwd, + [ + "config", + "--local", + "--no-includes", + "--get", + "remote.origin.pushurl", + ], + )?; + if local.as_deref().map(str::trim) != Some(current) { + bail!("origin's push URL `{shown}` is set outside this repository's Git config; remove it there and run again"); + } + Ok(()) +} + +#[cfg(test)] +mod tests { + #[test] + fn conflict_message_hides_embedded_credentials() { + let current = "https://synthetic-secret@example.com/other/library.git"; + let error = super::check_current( + std::path::Path::new("."), + Some(current), + "https://github.com/contributor/library.git", + false, + ) + .unwrap_err() + .to_string(); + assert!(error.contains("https://example.com/other/library.git")); + assert!(!error.contains("synthetic-secret")); + } +} diff --git a/src/commands/mod.rs b/src/commands/mod.rs index 78e2d99..48ac7b4 100644 --- a/src/commands/mod.rs +++ b/src/commands/mod.rs @@ -10,6 +10,7 @@ pub mod config; pub mod diff; pub mod doctor; pub mod fetch; +pub mod fork; pub mod git_credential; pub mod git_passthrough; pub mod handoff; diff --git a/src/commands/project.rs b/src/commands/project.rs index 3205c57..c4a72f5 100644 --- a/src/commands/project.rs +++ b/src/commands/project.rs @@ -1,6 +1,6 @@ use crate::commands::agents::write_project_agents_md; use crate::commands::base::validate_configured_base; -use crate::git::{current_branch, git_output_optional, git_root, infer_base_branch}; +use crate::git::{current_branch, git_output, git_output_optional, git_root, infer_base_branch}; use crate::ids::{short_sha, slugify}; use crate::model::{ CheckoutMode, KnitConfig, KnitProject, ProjectRepoEntry, ProjectRunCommand, PROJECT_CONFIG_FILE, @@ -16,7 +16,7 @@ use std::collections::BTreeMap; use std::ffi::OsStr; use std::ffi::OsString; use std::fs; -use std::path::Path; +use std::path::{Path, PathBuf}; pub fn init_project(name: &str, agents: bool) -> Result<()> { let cwd = std::env::current_dir().context("failed to read current directory")?; @@ -67,12 +67,192 @@ pub fn init_project(name: &str, agents: bool) -> Result<()> { Ok(()) } +pub fn init_project_with_repos( + name: &str, + refs: &[String], + agents: bool, + mode: crate::commands::fork::ForkMode, + owner: Option<&str>, +) -> Result<()> { + if refs.is_empty() { + return init_project(name, agents); + } + let repos = parse_repo_refs(refs)?; + let jobs = crate::parallel::git_jobs()?; + init_project(name, false)?; + let root = find_knit_root(&std::env::current_dir()?).context("No Knit workspace found")?; + let _credentials = crate::auth::activate_clone_root(root.clone()); + let (send, receive) = std::sync::mpsc::channel(); + std::thread::scope(|scope| { + crate::parallel::spawn_bounded(scope, &repos, jobs, |(id, url)| { + let result = clone_url(&root, id, Path::new(url)) + .and_then(|path| path.context("repository reference is not a clone URL")); + let _ = send.send((id.clone(), result)); + }); + }); + drop(send); + let mut results: BTreeMap<_, _> = receive.into_iter().collect(); + let mut failed = false; + let mut announced = false; + for (id, _) in &repos { + let result = results + .remove(id) + .context("clone worker returned no result")? + .and_then(|path| { + add_project_repo_inner(id, &path, None, false, false, true)?; + apply_project_repo_mode(&root, id, mode, owner, &mut announced) + }); + if let Err(error) = result { + eprintln!("{id}: {error:#}"); + failed = true; + } + } + if agents { + let project: KnitProject = read_json(&project_path(&root, &slugify(name)))?; + write_project_agents_md(&root, &project)?; + } + if failed { + bail!("one or more repositories could not be initialized"); + } + Ok(()) +} + +fn parse_repo_refs(refs: &[String]) -> Result<Vec<(String, String)>> { + let mut repos = Vec::new(); + let mut ids = std::collections::BTreeSet::new(); + for input in refs { + let (explicit_id, reference) = match input.split_once('=') { + Some((id, reference)) if !id.contains(['/', ':']) => { + if id.is_empty() { + bail!("invalid repository reference `{input}`: empty id"); + } + (Some(id), reference) + } + _ => (None, input.as_str()), + }; + if reference.is_empty() + || reference + .chars() + .any(|ch| ch.is_whitespace() || ch.is_control()) + || reference.contains('\\') + { + bail!("invalid repository reference `{input}`"); + } + let (url, repo_name) = if is_clone_url(reference) { + let path = if reference.contains("://") { + let url = url::Url::parse(reference) + .with_context(|| format!("invalid repository reference `{input}`"))?; + if !matches!(url.scheme(), "https" | "http" | "ssh" | "git" | "file") + || (url.scheme() != "file" && url.host_str().is_none()) + || url.query().is_some() + || url.fragment().is_some() + { + bail!("invalid repository reference `{input}`"); + } + url.path().to_owned() + } else { + let (host, path) = reference.split_once(':').unwrap(); + if host + .split_once('@') + .is_some_and(|(user, host)| user.is_empty() || host.is_empty()) + { + bail!("invalid repository reference `{input}`"); + } + path.to_owned() + }; + let basename = path.trim_end_matches('/').rsplit('/').next().unwrap_or(""); + let name = basename.strip_suffix(".git").unwrap_or(basename); + if name.is_empty() || name == "." || name == ".." { + bail!("invalid repository reference `{input}`"); + } + (reference.to_owned(), name.to_owned()) + } else { + let parts: Vec<_> = reference.split('/').collect(); + if parts.len() != 2 + || parts.iter().any(|part| { + part.is_empty() + || *part == "." + || *part == ".." + || !part + .bytes() + .all(|b| b.is_ascii_alphanumeric() || b"-_.".contains(&b)) + }) + { + bail!("invalid repository reference `{input}`; use owner/name, a clone URL, or id=reference"); + } + let name = parts[1].strip_suffix(".git").unwrap_or(parts[1]); + if name.is_empty() { + bail!("invalid repository reference `{input}`"); + } + ( + format!("https://github.com/{}/{name}.git", parts[0]), + name.to_owned(), + ) + }; + let id = slugify(explicit_id.unwrap_or(&repo_name)); + if !ids.insert(id.clone()) { + bail!("duplicate repository id `{id}`; use id=reference to disambiguate"); + } + repos.push((id, url)); + } + Ok(repos) +} + +pub fn add_project_repo_with_mode( + repo_id: &str, + repo_path: &Path, + base: Option<&str>, + observe: bool, + agents: bool, + mode: Option<crate::commands::fork::ForkMode>, + owner: Option<&str>, +) -> Result<()> { + add_project_repo(repo_id, repo_path, base, observe, agents)?; + let Some(mode) = mode else { + return Ok(()); + }; + let root = find_knit_root(&std::env::current_dir()?).context("No Knit workspace found")?; + let _credentials = crate::auth::activate_clone_root(root.clone()); + apply_project_repo_mode(&root, &slugify(repo_id), mode, owner, &mut false) + .map_err(|error| anyhow::anyhow!("{}: {error:#}", slugify(repo_id))) +} + +fn apply_project_repo_mode( + root: &Path, + id: &str, + mode: crate::commands::fork::ForkMode, + owner: Option<&str>, + announced: &mut bool, +) -> Result<()> { + let project_id = load_config(root)? + .active_project + .context("No active Knit project")?; + let project: KnitProject = read_json(&project_path(root, &project_id))?; + let repo = project + .repos + .iter() + .find(|repo| repo.id == id) + .with_context(|| format!("unknown project repo `{id}`"))?; + crate::commands::fork::apply_mode(repo, mode, owner, announced) +} + pub fn add_project_repo( repo_id: &str, repo_path: &Path, base: Option<&str>, observe: bool, agents: bool, +) -> Result<()> { + add_project_repo_inner(repo_id, repo_path, base, observe, agents, false) +} + +fn add_project_repo_inner( + repo_id: &str, + repo_path: &Path, + base: Option<&str>, + observe: bool, + agents: bool, + quiet: bool, ) -> Result<()> { let cwd = std::env::current_dir().context("failed to read current directory")?; let root = @@ -85,6 +265,9 @@ pub fn add_project_repo( let _lock = acquire_named_lock(&root, &format!("project-{project_id}"))?; let path = project_path(&root, project_id); let mut project: KnitProject = read_json(&path)?; + let _credentials = crate::auth::activate_clone_root(root.clone()); + let cloned = clone_url(&root, repo_id, repo_path)?; + let repo_path = cloned.as_deref().unwrap_or(repo_path); let (repo, base_source) = resolve_project_repo(repo_id, repo_path, base, observe)?; let resolved_base = repo.base_branch.clone(); @@ -98,9 +281,13 @@ pub fn add_project_repo( let publish = existing.publish.take(); *existing = repo.clone(); existing.publish = publish; - println!("{} {}", out::movement("updated"), out::repo(&repo.id)); + if !quiet { + println!("{} {}", out::movement("updated"), out::repo(&repo.id)); + } } else { - println!("{} {}", out::movement("added"), out::repo(&repo.id)); + if !quiet { + println!("{} {}", out::movement("added"), out::repo(&repo.id)); + } project.repos.push(repo); } @@ -119,12 +306,14 @@ pub fn add_project_repo( out::warn("Warning:") ); } - println!( - "{} {} ({})", - out::heading("Base branch:"), - out::branch(&resolved_base), - out::muted(base_source) - ); + if !quiet { + println!( + "{} {} ({})", + out::heading("Base branch:"), + out::branch(&resolved_base), + out::muted(base_source) + ); + } if agents { let agents_path = write_project_agents_md(&root, &project)?; println!( @@ -861,3 +1050,69 @@ fn resolve_project_repo( base_source, )) } + +fn clone_url(root: &Path, id: &str, input: &Path) -> Result<Option<PathBuf>> { + let url = input.to_string_lossy(); + if input.exists() || !is_clone_url(&url) { + return Ok(None); + } + let target = root.join(slugify(id)); + if target.exists() { + let origin = git_output_optional(&target, ["config", "--get", "remote.origin.url"]) + .ok() + .flatten(); + if !origin.is_some_and(|origin| { + crate::contribution::same_repository(origin.trim(), &url).unwrap_or(false) + }) { + bail!("{} exists and is not a clone of {url}", target.display()); + } + return Ok(Some(target)); + } + git_output( + root, + [ + OsStr::new("clone"), + OsStr::new("--filter=blob:none"), + OsStr::new("--"), + OsStr::new(url.as_ref()), + target.as_os_str(), + ], + )?; + Ok(Some(target)) +} + +pub(crate) fn is_clone_url(value: &str) -> bool { + value.contains("://") + || value.split_once(':').is_some_and(|(host, path)| { + !(host.is_empty() + || host.contains(['/', '\\']) + || path.is_empty() + || (host.len() == 1 && host.as_bytes()[0].is_ascii_alphabetic())) + }) +} + +#[cfg(test)] +mod clone_url_tests { + #[test] + fn urls_are_told_apart_from_paths() { + for url in [ + "https://github.com/example/repo.git", + "ssh://git@github.com/example/repo.git", + "git@github.com:example/repo.git", + "github.com:upstream-org/library.git", + "file:///tmp/repo", + ] { + assert!(super::is_clone_url(url), "{url}"); + } + for path in [ + "../repo", + "./repo", + "/tmp/repo", + "repo", + "C:\\repo", + "dir/a@b:c", + ] { + assert!(!super::is_clone_url(path), "{path}"); + } + } +} diff --git a/src/commands/push.rs b/src/commands/push.rs index 2f461a0..fa1b695 100644 --- a/src/commands/push.rs +++ b/src/commands/push.rs @@ -484,7 +484,7 @@ fn bundle_recorded_tip(bundle: &ChangeGroup, repo: &RepoEntry, tip: &str) -> boo }) } -fn sanitized_push_url(remote: &str) -> String { +pub(crate) fn sanitized_push_url(remote: &str) -> String { if let Ok(mut url) = url::Url::parse(remote) { if url.has_host() { let _ = url.set_username(""); diff --git a/src/commands/remote/client.rs b/src/commands/remote/client.rs index 3ec8c5d..9d53f8e 100644 --- a/src/commands/remote/client.rs +++ b/src/commands/remote/client.rs @@ -18,6 +18,7 @@ use anyhow::{bail, Context, Result}; use serde::de::DeserializeOwned; use serde::Deserialize; use serde_json::Value; +use std::collections::BTreeMap; use std::ffi::OsString; use std::io::Read; use std::path::{Path, PathBuf}; @@ -217,7 +218,32 @@ pub(super) fn fetch_project_export( } None => format!("/projects/{slug}/export?artifacts=none&history=false"), }; - request_json_with_optional_token(remote, token, "GET", &path, None) + let mut export: RemoteProjectExport = + request_json_with_optional_token(remote, token, "GET", &path, None)?; + drop_records_contradicting_membership(&mut export); + Ok(export) +} + +/// A bundle pushed from a fork checkout leaves the fork in the server's +/// repository inventory under the member's id. Membership names the +/// repository everyone clones, so a record that shares a member's id but not +/// its remote is that fork, never a second copy of the member. +fn drop_records_contradicting_membership(export: &mut RemoteProjectExport) { + let Some(membership) = export.knit_project.as_ref() else { + return; + }; + let remotes: BTreeMap<&str, &str> = membership + .repos + .iter() + .filter_map(|entry| Some((entry.id.as_str(), entry.remote.as_deref()?))) + .collect(); + export.repositories.retain(|record| { + let local_id = super::clone::export_repo_local_id(record); + match (remotes.get(local_id.as_str()), record.remote_url.as_deref()) { + (Some(member), Some(url)) => super::handoff::same_repository_url(member, url), + _ => true, + } + }); } /// Fetch one bundle's current artifact by remote bundle id, returning the diff --git a/src/commands/remote/clone.rs b/src/commands/remote/clone.rs index e1e7cdb..dae76eb 100644 --- a/src/commands/remote/clone.rs +++ b/src/commands/remote/clone.rs @@ -15,6 +15,7 @@ use super::{ use crate::commands::agents::{ print_bundle_worktree_agents_summary, write_bundle_worktree_agents_md, }; +use crate::commands::fork::checkout_forks; use crate::commands::worktree::materialize_repos; use crate::git::{current_branch, git_output, is_git_worktree, ref_exists}; use crate::ids::slugify; @@ -79,6 +80,11 @@ struct CloneDocumentRepo { status: &'static str, #[serde(skip_serializing_if = "Option::is_none")] error: Option<String>, + /// `direct`, `fork`, or `unknown` for a fresh clone of a forge repository. + #[serde(skip_serializing_if = "Option::is_none")] + push: Option<&'static str>, + #[serde(skip_serializing_if = "Option::is_none")] + fork: Option<String>, } #[derive(Debug, Serialize)] @@ -1305,10 +1311,16 @@ fn clone_document( .map(|repository| { let local_id = export_repo_local_id(repository); if repo_paths.contains_key(&local_id) { + let push = checkout_forks().outcome(&local_id); CloneDocumentRepo { id: local_id, status: "cloned", error: None, + push: push.as_ref().map(|push| push.status()), + fork: push + .as_ref() + .and_then(|push| push.fork()) + .map(str::to_owned), } } else { let error = failed_repos @@ -1319,6 +1331,8 @@ fn clone_document( id: local_id, status: "failed", error, + push: None, + fork: None, } } }) @@ -1330,6 +1344,8 @@ fn clone_document( id: failed_id.clone(), status: "failed", error: Some(error.clone()), + push: None, + fork: None, }); } } @@ -1922,7 +1938,8 @@ pub(super) fn clone_export_repositories( /// Clone (or adopt) one exported repository. Authentication comes from the /// installed Git credential helpers; a failed non-public clone carries the -/// access hint. +/// access hint. Only a fresh clone decides between pushing upstream and +/// pushing to a fork; an adopted checkout keeps its remotes. pub(super) fn clone_one_export_repository( target_root: &Path, repository: &RemoteExportRepository, @@ -1996,6 +2013,12 @@ pub(super) fn clone_one_export_repository( ); checkout_export_base_branch(&repo_path, repository)?; + if let Err(error) = checkout_forks().apply(&local_id, &repo_path) { + // Leaving the checkout would make the next run adopt it without a + // push target. + let _ = fs::remove_dir_all(&repo_path); + return Err(error.context(format!("{local_id}: failed to set up pushing"))); + } Ok((local_id, repo_path)) } @@ -2648,11 +2671,15 @@ mod tests { id: "backend".to_string(), status: "cloned", error: None, + push: Some("fork"), + fork: Some("contributor/backend".to_string()), }, CloneDocumentRepo { id: "frontend".to_string(), status: "failed", error: Some("git clone failed".to_string()), + push: None, + fork: None, }, ], cloned_repo_count: 1, @@ -2683,7 +2710,7 @@ mod tests { "project": {"id": "knit-tools", "owner": "marc-merino", "slug": "knit-tools"}, "targetPath": "/abs/path/to/knit-tools", "repos": [ - {"id": "backend", "status": "cloned"}, + {"id": "backend", "status": "cloned", "push": "fork", "fork": "contributor/backend"}, {"id": "frontend", "status": "failed", "error": "git clone failed"}, ], "clonedRepoCount": 1, diff --git a/src/lib.rs b/src/lib.rs index f28d9a7..dbaea94 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -68,10 +68,34 @@ pub fn run(cli: Cli) -> Result<()> { workspace, force, json, - } => commands::handoff::handoff_in(&project, &slug, workspace.as_deref(), force, json), + forks, + } => { + install_checkout_forks(forks); + commands::handoff::handoff_in(&project, &slug, workspace.as_deref(), force, json) + } cli::HandoffCommand::Status { json } => commands::handoff::handoff_status(json), }, - Commands::Init { name, agents } => commands::init_project(&name, agents), + Commands::Fork { + repo_ids, + owner, + dry_run, + json, + force, + } => commands::fork::fork_repos(&repo_ids, owner.as_deref(), dry_run, json, force), + Commands::Init { + name, + repos, + agents, + fork, + no_fork, + owner, + } => commands::project::init_project_with_repos( + &name, + &repos, + agents, + commands::fork::ForkMode::from_flags(fork, no_fork), + owner.as_deref(), + ), Commands::Agents { project } => commands::refresh_agents(project.as_deref()), Commands::Project { command } => match command { ProjectCommand::Auth { project, repos } => { @@ -83,7 +107,22 @@ pub fn run(cli: Cli) -> Result<()> { base, observe, agents, - } => commands::add_project_repo(&repo_id, &repo_path, base.as_deref(), observe, agents), + fork, + no_fork, + owner, + } => commands::project::add_project_repo_with_mode( + &repo_id, + &repo_path, + base.as_deref(), + observe, + agents, + (fork + || no_fork + || (commands::project::is_clone_url(&repo_path.to_string_lossy()) + && !repo_path.exists())) + .then(|| commands::fork::ForkMode::from_flags(fork, no_fork)), + owner.as_deref(), + ), ProjectCommand::SetBase { repo_id, branch, @@ -250,22 +289,26 @@ pub fn run(cli: Cli) -> Result<()> { repos, credentials, json, - } => commands::clone_project_from_remote( - &project, - target.as_deref(), - remote.as_deref(), - url.as_deref(), - token.as_deref(), - active_bundle.as_deref(), - !no_worktree, - prefer_https, - commands::CloneScopeRequest { - view: view.as_deref(), - repos: &repos, - }, - &credentials, - json, - ), + forks, + } => { + install_checkout_forks(forks); + commands::clone_project_from_remote( + &project, + target.as_deref(), + remote.as_deref(), + url.as_deref(), + token.as_deref(), + active_bundle.as_deref(), + !no_worktree, + prefer_https, + commands::CloneScopeRequest { + view: view.as_deref(), + repos: &repos, + }, + &credentials, + json, + ) + } Commands::Add { repos, intent_to_add, @@ -311,7 +354,10 @@ pub fn run(cli: Cli) -> Result<()> { }, }, Some(BundleCommand::Worktree) => commands::create_worktrees(), - Some(BundleCommand::Pull { slug, json }) => commands::pull_bundle_by_slug(&slug, json), + Some(BundleCommand::Pull { slug, json, forks }) => { + install_checkout_forks(forks); + commands::pull_bundle_by_slug(&slug, json) + } Some(BundleCommand::Add { repos, base, @@ -444,20 +490,24 @@ pub fn run(cli: Cli) -> Result<()> { remote, no_remote, merge, - } => commands::pull( - &repos, - all, - rebase, - force, - feature, - main, - base, - current, - bundles, - remote.as_deref(), - no_remote, - merge, - ), + forks, + } => { + install_checkout_forks(forks); + commands::pull( + &repos, + all, + rebase, + force, + feature, + main, + base, + current, + bundles, + remote.as_deref(), + no_remote, + merge, + ) + } Commands::Push { allow_foreign_author, repos, @@ -1012,7 +1062,9 @@ pub fn run(cli: Cli) -> Result<()> { remote, artifacts_only: _, repos, + forks, }) => { + install_checkout_forks(forks); let targets = commands::remote::SyncTargets::resolve( targets.bundles, targets.history, @@ -1135,3 +1187,10 @@ fn effective_publish_provider(provider: Option<String>, github: bool) -> Option< provider } } + +fn install_checkout_forks(forks: cli::CheckoutForkArgs) { + commands::fork::set_checkout_forks( + commands::fork::ForkMode::from_flags(forks.fork, forks.no_fork), + forks.owner, + ); +} diff --git a/src/providers/github/api.rs b/src/providers/github/api.rs index 4fd8194..993c871 100644 --- a/src/providers/github/api.rs +++ b/src/providers/github/api.rs @@ -133,11 +133,7 @@ pub(crate) fn preflight_contribution(target: &PrTarget, verify_head: bool) -> Re { bail!("GitHub repository identity differs from recorded contribution"); } - if !(id.source.eq_ignore_ascii_case(&id.target) - || source["fork"] == true - && source["parent"]["id"].as_u64().is_some() - && source["parent"]["id"] == target_repo["id"]) - { + if !(id.source.eq_ignore_ascii_case(&id.target) || is_direct_fork(&source, &target_repo)) { bail!("sourceRemote is not a verified direct fork of targetRemote"); } let base = encode_query_component(&id.base); @@ -741,6 +737,134 @@ fn encode_path(input: &str, allow_slash: bool) -> String { encoded } +fn is_direct_fork(source: &serde_json::Value, upstream: &serde_json::Value) -> bool { + source["fork"] == true + && source["parent"]["id"].as_u64().is_some() + && source["parent"]["id"] == upstream["id"] +} + +pub(super) fn can_push(target: &PrTarget) -> Result<Option<bool>> { + let name = target + .repo_full_name + .as_deref() + .context("missing repository")?; + let output = github_api_output(target, "GET", &format!("repos/{name}"), None)?; + let repo: serde_json::Value = + serde_json::from_str(&output).context("failed to parse GitHub repository JSON")?; + Ok(repo["permissions"]["push"].as_bool()) +} + +pub(super) fn ensure_fork( + target: &PrTarget, + owner: Option<&str>, + dry_run: bool, +) -> Result<crate::providers::ForkRepository> { + let upstream_name = target + .repo_full_name + .as_deref() + .context("missing upstream repository")?; + let get = |endpoint: &str| -> Result<serde_json::Value> { + Ok(serde_json::from_str(&github_api_output( + target, "GET", endpoint, None, + )?)?) + }; + let upstream = get(&format!("repos/{upstream_name}"))?; + let explicit_owner = owner.is_some(); + let owner = match owner { + Some(owner) => owner.to_owned(), + None => get("user")?["login"] + .as_str() + .context("GitHub user has no login")? + .to_owned(), + }; + let name = upstream["name"] + .as_str() + .context("GitHub repository has no name")?; + let expected = format!("{owner}/{name}"); + match get(&format!("repos/{expected}")) { + Ok(repo) if is_direct_fork(&repo, &upstream) => { + return fork_repository(&repo, &owner, &upstream, true) + } + Ok(_) => {} + Err(error) if is_not_found(&error) => {} + Err(error) => return Err(error), + } + if dry_run { + return Ok(crate::providers::ForkRepository { + clone_url: format!("https://github.com/{expected}.git"), + ssh_url: format!("git@github.com:{expected}.git"), + full_name: expected, + existed: false, + }); + } + let payload = match explicit_owner { + true => json!({ "organization": owner }), + false => json!({}), + }; + // Returns the caller's existing fork, even a renamed one, instead of a second copy. + let created: serde_json::Value = serde_json::from_str(&github_api_output( + target, + "POST", + &format!("repos/{upstream_name}/forks"), + Some(&payload.to_string()), + )?)?; + let full_name = created["full_name"] + .as_str() + .context("GitHub fork response has no full_name")?; + let interval = std::time::Duration::from_millis( + std::env::var("KNIT_FORK_POLL_INTERVAL_MS") + .ok() + .and_then(|value| value.parse().ok()) + .unwrap_or(1000), + ); + for _ in 0..60 { + match get(&format!("repos/{full_name}")) { + Ok(repo) => return fork_repository(&repo, &owner, &upstream, false), + Err(error) if is_not_found(&error) => std::thread::sleep(interval), + Err(error) => return Err(error), + } + } + bail!("timed out waiting for GitHub to create {full_name}") +} + +fn fork_repository( + repo: &serde_json::Value, + owner: &str, + upstream: &serde_json::Value, + existed: bool, +) -> Result<crate::providers::ForkRepository> { + let full_name = repo["full_name"] + .as_str() + .context("fork has no full_name")?; + let upstream_name = upstream["full_name"].as_str().unwrap_or_default(); + if !full_name + .split_once('/') + .is_some_and(|(login, _)| login.eq_ignore_ascii_case(owner)) + || !is_direct_fork(repo, upstream) + { + bail!("{full_name} is not a direct fork of {upstream_name} owned by {owner}"); + } + Ok(crate::providers::ForkRepository { + full_name: full_name.into(), + clone_url: repo["clone_url"] + .as_str() + .context("fork has no clone_url")? + .into(), + ssh_url: repo["ssh_url"] + .as_str() + .context("fork has no ssh_url")? + .into(), + existed, + }) +} + +fn is_not_found(error: &anyhow::Error) -> bool { + error + .downcast_ref::<crate::retry::HttpFailure>() + .is_some_and(|failure| failure.status == 404) + || format!("{error:#}").contains("HTTP 404") +} + #[cfg(test)] mod tests { use super::*; diff --git a/src/providers/github/mod.rs b/src/providers/github/mod.rs index 54720ac..fbe257d 100644 --- a/src/providers/github/mod.rs +++ b/src/providers/github/mod.rs @@ -49,6 +49,19 @@ pub fn commit_check_runs( } impl Forge for GitHub { + fn can_push(&self, target: &PrTarget) -> Result<Option<bool>> { + api::can_push(target) + } + + fn ensure_fork( + &self, + target: &PrTarget, + owner: Option<&str>, + dry_run: bool, + ) -> Result<super::ForkRepository> { + api::ensure_fork(target, owner, dry_run) + } + fn id(&self) -> &'static str { "github" } diff --git a/src/providers/mod.rs b/src/providers/mod.rs index e65665f..a2f9083 100644 --- a/src/providers/mod.rs +++ b/src/providers/mod.rs @@ -216,8 +216,29 @@ pub enum BranchMergeStatus { AlreadyContained, } +#[derive(Debug, Clone)] +pub struct ForkRepository { + pub full_name: String, + pub clone_url: String, + pub ssh_url: String, + pub existed: bool, +} + /// A code host that exposes review objects through a CLI tool. pub trait Forge { + fn can_push(&self, _target: &PrTarget) -> Result<Option<bool>> { + Ok(None) + } + + fn ensure_fork( + &self, + _target: &PrTarget, + _owner: Option<&str>, + _dry_run: bool, + ) -> Result<ForkRepository> { + bail!("{} forks cannot be created by Knit yet", self.id()) + } + /// Stable provider id recorded on publications, e.g. `github`. fn id(&self) -> &'static str; /// Review object kind recorded on publications. diff --git a/tests/bitbucket_auth_fallback.rs b/tests/bitbucket_auth_fallback.rs index b586394..72575fa 100644 --- a/tests/bitbucket_auth_fallback.rs +++ b/tests/bitbucket_auth_fallback.rs @@ -410,6 +410,7 @@ impl World { &self.root, &[ "clone", + "--no-fork", "acme/demo", self.target.to_str().unwrap(), "--remote", @@ -428,7 +429,12 @@ impl World { fn pull_bundles(&self) -> (String, String, bool) { let mut env: Vec<(&str, &str)> = self.env_vec(); env.push(("KNIT_REMOTE_HOSTED_TOKEN", "test-ledger-token")); - knit_run(&self.target, &["pull", "--bundles"], &env, None) + knit_run( + &self.target, + &["pull", "--bundles", "--no-fork"], + &env, + None, + ) } } @@ -722,6 +728,7 @@ fn explicit_credential_failure_never_falls_back() { &world.root, &[ "clone", + "--no-fork", "acme/demo", world.target.to_str().unwrap(), "--remote", diff --git a/tests/clone_credentials.rs b/tests/clone_credentials.rs index bb85c91..f1e1c8f 100644 --- a/tests/clone_credentials.rs +++ b/tests/clone_credentials.rs @@ -517,6 +517,7 @@ fn selected_credential_clones_scoped_private_repo_without_borrowing_the_parent() &parent, &[ "clone", + "--no-fork", "acme/demo", target.to_str().unwrap(), "--remote", @@ -538,7 +539,7 @@ fn selected_credential_clones_scoped_private_repo_without_borrowing_the_parent() .unwrap_or_else(|error| panic!("stdout must be pure JSON ({error}): {stdout}")); assert_eq!( document["repos"], - json!([{"id": "backend", "status": "cloned"}]) + json!([{"id": "backend", "status": "cloned", "push": "direct"}]) ); assert_eq!(document["clonedRepoCount"], 1); assert_eq!(document["scopeView"], json!("scope")); @@ -729,6 +730,7 @@ fn denied_selected_credential_fails_closed_with_an_actionable_redacted_error() { &root, &[ "clone", + "--no-fork", "acme/demo", target.to_str().unwrap(), "--remote", @@ -824,7 +826,12 @@ fn denied_selected_credential_fails_closed_with_an_actionable_redacted_error() { ]), ) .unwrap(); - let (out, err, ok) = knit_run(&target, &["pull", "--bundles"], &retry_env, None); + let (out, err, ok) = knit_run( + &target, + &["pull", "--bundles", "--no-fork"], + &retry_env, + None, + ); assert!(ok, "{out}{err}"); assert!(target.join("backend/.git").exists(), "{out}{err}"); assert_eq!( @@ -853,6 +860,7 @@ fn denied_selected_credential_fails_closed_with_an_actionable_redacted_error() { &root, &[ "clone", + "--no-fork", "acme/demo", blocked.to_str().unwrap(), "--remote", @@ -897,6 +905,7 @@ fn denied_selected_credential_fails_closed_with_an_actionable_redacted_error() { &root, &[ "clone", + "--no-fork", "acme/demo", unavailable.to_str().unwrap(), "--remote", @@ -950,6 +959,7 @@ fn credential_selection_fails_before_export_fetch_or_target_mutation() { &root, &[ "clone", + "--no-fork", "acme/demo", unknown_target.to_str().unwrap(), "--remote", @@ -982,6 +992,7 @@ fn credential_selection_fails_before_export_fetch_or_target_mutation() { &root, &[ "clone", + "--no-fork", "acme/demo", duplicate_target.to_str().unwrap(), "--remote", @@ -1090,6 +1101,7 @@ fn interrupted_clone_resumes_into_same_root_adopting_matching_checkouts() { &root, &[ "clone", + "--no-fork", "acme/demo", target.to_str().unwrap(), "--remote", @@ -1186,6 +1198,7 @@ fn clone_refuses_unrelated_entries_and_foreign_checkouts() { &root, &[ "clone", + "--no-fork", "acme/demo", with_file.to_str().unwrap(), "--remote", @@ -1231,6 +1244,7 @@ fn clone_refuses_unrelated_entries_and_foreign_checkouts() { &root, &[ "clone", + "--no-fork", "acme/demo", with_foreign.to_str().unwrap(), "--remote", @@ -1267,6 +1281,7 @@ fn clone_refuses_unrelated_entries_and_foreign_checkouts() { &root, &[ "clone", + "--no-fork", "acme/demo", configured.to_str().unwrap(), "--remote", @@ -1374,6 +1389,7 @@ fn clone_without_selection_keeps_ambient_access_and_never_borrows_the_parent() { &parent, &[ "clone", + "--no-fork", "acme/demo", target.to_str().unwrap(), "--remote", @@ -1431,6 +1447,7 @@ fn clone_without_selection_keeps_ambient_access_and_never_borrows_the_parent() { &parent, &[ "clone", + "--no-fork", "acme/demo", gh_target.to_str().unwrap(), "--remote", @@ -1496,6 +1513,7 @@ fn clone_without_selection_keeps_ambient_access_and_never_borrows_the_parent() { &parent, &[ "clone", + "--no-fork", "acme/demo", private_target.to_str().unwrap(), "--remote", @@ -1593,6 +1611,7 @@ fn resume_rejects_renamed_checkouts_masqueraded_folders_and_scaffold_symlinks() &root, &[ "clone", + "--no-fork", "acme/demo", renamed.to_str().unwrap(), "--remote", @@ -1626,6 +1645,7 @@ fn resume_rejects_renamed_checkouts_masqueraded_folders_and_scaffold_symlinks() &root, &[ "clone", + "--no-fork", "acme/demo", inside.to_str().unwrap(), "--remote", @@ -1657,6 +1677,7 @@ fn resume_rejects_renamed_checkouts_masqueraded_folders_and_scaffold_symlinks() &root, &[ "clone", + "--no-fork", "acme/demo", linked.to_str().unwrap(), "--remote", @@ -1758,6 +1779,7 @@ fn noninteractive_declared_group_clone_is_partial_with_scriptable_guidance() { &root, &[ "clone", + "--no-fork", "acme/demo", target.to_str().unwrap(), "--remote", @@ -1882,6 +1904,7 @@ fn partial_selection_records_ambient_after_assignments_so_check_passes() { &root, &[ "clone", + "--no-fork", "acme/demo", target.to_str().unwrap(), "--remote", @@ -1993,7 +2016,14 @@ fn ordinary_ledger_token_skips_optional_export_but_environment_denials_remain_vi ), ); assert!(ok, "{out}{err}"); - let mut args = vec!["clone", "demo", "--remote", "hosted", "--no-worktree"]; + let mut args = vec![ + "clone", + "--no-fork", + "demo", + "--remote", + "hosted", + "--no-worktree", + ]; if prefer_https { args.push("--prefer-https"); } @@ -2108,6 +2138,7 @@ fn saved_bitbucket_default_authenticates_mixed_clone_across_hosted_rename() { cwd, &[ "clone", + "--no-fork", &reference, target.to_str().unwrap(), "--remote", diff --git a/tests/fixtures/bitbucket_clone_https.py b/tests/fixtures/bitbucket_clone_https.py index ec1501e..cacd3aa 100644 --- a/tests/fixtures/bitbucket_clone_https.py +++ b/tests/fixtures/bitbucket_clone_https.py @@ -176,7 +176,8 @@ def do_GET(self): workspace = root / slug result = run([binary, 'clone', f'example/{slug}', workspace, '--remote', 'hosted', '--url', f'http://127.0.0.1:{ledger.server_port}', - '--token', 'synthetic-ledger', '--no-worktree'], check=False) + '--token', 'synthetic-ledger', '--no-worktree', '--no-fork'], + check=False) output = result.stdout + result.stderr if expect_rejection: assert not (workspace / 'service' / 'hello.txt').exists() diff --git a/tests/fixtures/fork_create_gh.py b/tests/fixtures/fork_create_gh.py new file mode 100644 index 0000000..393dddc --- /dev/null +++ b/tests/fixtures/fork_create_gh.py @@ -0,0 +1,94 @@ +#!/usr/bin/env python3 +"""Fail-closed GitHub fork API: all identities and storage are synthetic.""" +import json +import os +import pathlib +import sys +import time +import urllib.parse + +root = pathlib.Path(os.environ['GH_FAKE_DIR']) +args = sys.argv[1:] +assert args[0] == 'api', args +endpoint = next(a for a in args[1:] if a.startswith(('repos/', 'users/', 'orgs/', 'user/')) or a == 'user') +method = args[args.index('--method') + 1] if '--method' in args else 'GET' +payload = json.loads(sys.stdin.read()) if '--input' in args else {} +with (root / 'calls').open('a') as stream: + stream.write(json.dumps({'method': method, 'endpoint': endpoint, 'payload': payload}) + '\n') +settings = json.loads((root / 'settings.json').read_text()) +if settings.get('missing_credentials'): + print('To get started with GitHub CLI, please run: gh auth login', file=sys.stderr) + sys.exit(1) +path = urllib.parse.urlsplit(endpoint).path.rstrip('/') +repo_settings = settings.pop('repos', {}) +upstream_name = 'library' +for candidate, overrides in repo_settings.items(): + repo_path = '/'.join(path.split('/')[:3]) + if repo_path in ( + 'repos/upstream-org/' + candidate, + 'repos/' + overrides.get('owner', settings.get('owner', 'contributor')) + '/' + overrides.get('name', candidate), + ): + upstream_name = candidate + settings.update(overrides) + break +created = root / ('created' if upstream_name == 'library' else 'created-' + upstream_name) +polled = root / ('polled' if upstream_name == 'library' else 'polled-' + upstream_name) +upstream_ref = 'upstream-org/' + upstream_name +if settings.get('expected_token'): + assert os.environ.get('GH_TOKEN') == settings['expected_token'], 'wrong repository credential' +owner = settings.get('owner', 'contributor') +name = settings.get('name', upstream_name) +upstream = {'id': 101, 'full_name': upstream_ref, 'name': upstream_name, + 'owner': {'login': 'upstream-org'}, 'fork': False, 'private': False, + 'clone_url': 'https://github.com/' + upstream_ref + '.git', + 'ssh_url': 'git@github.com:' + upstream_ref + '.git', 'default_branch': 'main'} +if 'push' in settings: + upstream['permissions'] = {'push': settings['push']} +fork = {'id': 202, 'full_name': owner + '/' + name, 'name': name, 'owner': {'login': owner}, + 'fork': True, 'parent': dict(upstream), 'source': dict(upstream), + 'clone_url': 'https://github.com/' + owner + '/' + name + '.git', + 'ssh_url': 'git@github.com:' + owner + '/' + name + '.git', 'default_branch': 'main'} +if settings.get('indirect') or (settings.get('post_indirect') and created.exists()): + fork['parent'] = {'id': 303, 'full_name': 'other/library'} +present = settings.get('existing', False) or created.exists() +if path == 'user': + value = {'login': 'contributor', 'id': 404} +elif path == 'repos/' + upstream_ref: + value = upstream +elif path in ('repos/' + upstream_ref + '/git/ref/heads/main', 'repos/' + owner + '/' + name + '/git/ref/heads/contribution'): + value = {'object': {'sha': settings['head']}} +elif path == 'repos/' + upstream_ref + '/pulls' and method == 'POST': + print('synthetic preflight complete (HTTP 422)', file=sys.stderr) + sys.exit(1) +elif path == 'repos/' + upstream_ref + '/forks' and method == 'POST': + if os.environ.get('FORK_CREATE_NOTICE_LOG'): + notice = pathlib.Path(os.environ['FORK_CREATE_NOTICE_LOG']).read_text() + assert 'public' in notice.lower(), 'notice must be visible before first POST' + assert len(notice.strip().splitlines()) == 1, notice + created.write_text('yes') + value = dict(fork) + if settings.get('post_indirect'): + value['parent'] = {'id': 303, 'full_name': 'other/library'} +elif path == 'repos/' + upstream_ref + '/forks' or path in ('users/' + owner + '/repos', 'orgs/' + owner + '/repos', 'user/repos'): + value = [fork] if present else [] +elif path in ('repos/' + owner + '/' + upstream_name, 'repos/' + owner + '/' + name): + if settings.get('hang_ready') and created.exists(): + (root / 'sleep-started').write_text(str(os.getpid())) + time.sleep(30) + (root / 'sleep-completed').write_text('unexpected') + # A renamed fork must be discovered from a list, not guessed by upstream name. + if not present or path != 'repos/' + owner + '/' + name or settings.get('never_ready'): + print('gh: Not Found (HTTP 404)', file=sys.stderr) + sys.exit(1) + if settings.get('delay_once') and created.exists() and not polled.exists(): + polled.write_text('yes') + print('gh: Not Found (HTTP 404)', file=sys.stderr) + sys.exit(1) + value = dict(fork) + if settings.get('get_mismatch'): + value['full_name'] = owner + '/unexpected-library' + value['clone_url'] = 'https://github.com/' + value['full_name'] + '.git' + value['ssh_url'] = 'git@github.com:' + value['full_name'] + '.git' +else: + raise AssertionError((method, endpoint, args)) +print(json.dumps(value)) diff --git a/tests/fixtures/fork_create_http.py b/tests/fixtures/fork_create_http.py new file mode 100644 index 0000000..b6c68ce --- /dev/null +++ b/tests/fixtures/fork_create_http.py @@ -0,0 +1,48 @@ +#!/usr/bin/env python3 +"""Loopback-only native transport around the same fail-closed fork API fixture.""" +import http.server +import json +import os +import pathlib +import subprocess +import sys +import time + +root = pathlib.Path(os.environ['GH_FAKE_DIR']) +class Handler(http.server.BaseHTTPRequestHandler): + def log_message(self, *args): + pass + + def handle_api(self): + with (root / 'headers').open('a') as stream: + stream.write(json.dumps({'authorization': self.headers.get('Authorization')}) + '\n') + settings = json.loads((root / 'settings.json').read_text()) + if settings.get('hang_ready') and (root / 'created').exists() and self.path == '/repos/contributor/library': + with (root / 'calls').open('a') as stream: + stream.write(json.dumps({'method': self.command, 'endpoint': self.path.lstrip('/'), 'payload': {}}) + '\n') + (root / 'sleep-started').write_text(str(os.getpid())) + time.sleep(30) + (root / 'sleep-completed').write_text('unexpected') + if self.headers.get('Authorization') != 'Bearer synthetic-upstream-token': + status, body = 401, b'{"message":"wrong credential binding"}' + else: + args = [sys.executable, sys.argv[1], 'api', self.path.lstrip('/'), '--method', self.command] + payload = None + if self.command == 'POST': + args += ['--input', '-'] + payload = self.rfile.read(int(self.headers.get('Content-Length', '0'))) + result = subprocess.run(args, input=payload, capture_output=True) + status = (202 if self.command == 'POST' else 200) if result.returncode == 0 else 404 if b'HTTP 404' in result.stderr else 500 + body = result.stdout if result.returncode == 0 else json.dumps({'message': result.stderr.decode()}).encode() + self.send_response(status) + self.send_header('Content-Type', 'application/json') + self.send_header('Content-Length', str(len(body))) + self.end_headers() + self.wfile.write(body) + + do_GET = handle_api + do_POST = handle_api + +server = http.server.HTTPServer(('127.0.0.1', 0), Handler) +print('http://127.0.0.1:' + str(server.server_port), flush=True) +server.serve_forever() diff --git a/tests/fork_create.rs b/tests/fork_create.rs new file mode 100644 index 0000000..bd9a859 --- /dev/null +++ b/tests/fork_create.rs @@ -0,0 +1,1502 @@ +mod common; +use common::*; +use serde_json::{json, Value}; +use std::{ + fs, + path::{Path, PathBuf}, +}; + +fn git<I, S>(cwd: &Path, args: I) -> String +where + I: IntoIterator<Item = S>, + S: AsRef<std::ffi::OsStr>, +{ + common::git(cwd, args).trim().to_owned() +} + +const UPSTREAM: &str = "https://github.com/upstream-org/library.git"; +const FORK: &str = "https://github.com/contributor/library.git"; + +struct Fixture { + root: PathBuf, + workspace: PathBuf, + local: PathBuf, + upstream: PathBuf, + bin: PathBuf, + api: PathBuf, +} +impl Fixture { + fn new() -> Self { + let root = unique_temp_dir(); + let (upstream, local, _) = init_remote_repo(&root, "library"); + let workspace = root.join("workspace"); + fs::create_dir_all(&workspace).unwrap(); + knit(&workspace, ["init", "demo"]); + knit( + &workspace, + ["project", "add", "library", local.to_str().unwrap()], + ); + git(&local, ["remote", "set-url", "origin", UPSTREAM]); + let bin = root.join("bin"); + let api = root.join("api"); + fs::create_dir_all(&api).unwrap(); + fs::create_dir_all(&bin).unwrap(); + write_fake_python_cli(&bin, "gh", include_str!("fixtures/fork_create_gh.py")); + let f = Self { + root, + workspace, + local, + upstream, + bin, + api, + }; + f.settings(json!({})); + f + } + fn settings(&self, value: Value) { + fs::write( + self.api.join("settings.json"), + serde_json::to_vec(&value).unwrap(), + ) + .unwrap(); + } + fn run(&self, args: &[&str]) -> String { + knit_with_fake_gh_env( + &self.workspace, + args, + &self.bin, + &self.api, + &[ + ("KNIT_GITHUB_API_TRANSPORT", "gh"), + ("KNIT_FORK_POLL_INTERVAL_MS", "1"), + ], + ) + } + fn fail(&self, args: &[&str]) -> String { + knit_fails_with_fake_gh_env( + &self.workspace, + args, + &self.bin, + &self.api, + &[ + ("KNIT_GITHUB_API_TRANSPORT", "gh"), + ("KNIT_FORK_POLL_INTERVAL_MS", "1"), + ], + ) + } + fn split(&self, args: &[&str], extra: &[(&str, &str)]) -> (String, String, bool) { + let original = std::env::var_os("PATH").unwrap_or_default(); + let path = std::env::join_paths( + std::iter::once(self.bin.clone()).chain(std::env::split_paths(&original)), + ) + .unwrap(); + let path = path.to_str().unwrap(); + let api = self.api.to_str().unwrap(); + let mut env = vec![ + ("PATH", path), + ("GH_FAKE_DIR", api), + ("KNIT_GITHUB_API_TRANSPORT", "gh"), + ("KNIT_FORK_POLL_INTERVAL_MS", "1"), + ("NO_COLOR", "1"), + ]; + env.extend_from_slice(extra); + let value = |key| { + env.iter() + .rev() + .find(|(name, _)| *name == key) + .map(|(_, value)| *value) + }; + if value("KNIT_GITHUB_API_TRANSPORT") == Some("native") { + assert!( + is_loopback_api(value("KNIT_GITHUB_API_BASE").unwrap_or("")), + "native fixture must provide a valid loopback API URL before invoking Knit" + ); + let home = + value("KNIT_HOME").expect("native fixture requires an explicit isolated home"); + assert!( + !Path::new(home).join("forge-auth.json").exists(), + "saved native credentials ignore API overrides" + ); + } + knit_split_output(&self.workspace, args, &env) + } + fn fresh_workspace() -> Self { + let mut f = Self::new(); + f.workspace = f.root.join("fresh"); + fs::create_dir_all(&f.workspace).unwrap(); + f + } + fn clone_split(&self, args: &[&str]) -> (String, String, bool) { + let key = format!("url.file://{}/.insteadOf", self.root.display()); + self.split( + args, + &[ + ("GIT_CONFIG_COUNT", "1"), + ("GIT_CONFIG_KEY_0", &key), + ("GIT_CONFIG_VALUE_0", "https://github.com/upstream-org/"), + ], + ) + } + fn post_count(&self) -> usize { + self.calls() + .iter() + .filter(|c| c["method"] == "POST") + .count() + } + fn calls(&self) -> Vec<Value> { + fs::read_to_string(self.api.join("calls")) + .unwrap_or_default() + .lines() + .map(|s| serde_json::from_str(s).unwrap()) + .collect() + } + fn project_bytes(&self) -> Vec<u8> { + fs::read(self.workspace.join(".knit/projects/demo.project.json")).unwrap() + } + fn config(&self) -> Vec<u8> { + fs::read(self.local.join(".git/config")).unwrap() + } + fn pushurl(&self) -> String { + git( + &self.local, + ["config", "--get-all", "remote.origin.pushurl"], + ) + } + fn no_posts(&self) { + assert!( + self.calls().iter().all(|c| c["method"] != "POST"), + "{:?}", + self.calls() + ); + } +} + +#[test] +fn creates_then_polls_without_changing_upstream() { + let f = Fixture::new(); + let project: Value = serde_json::from_slice(&f.project_bytes()).unwrap(); + f.settings(json!({"delay_once": true})); + let output = f.run(&["fork", "library", "--json"]); + let report: Value = serde_json::from_str(&output).expect("fork --json is machine readable"); + assert_eq!(report["repos"][0]["status"], "forked"); + assert_eq!(report["repos"][0]["pushUrl"], FORK); + assert_eq!(f.pushurl(), FORK); + assert_eq!( + git(&f.local, ["config", "--get", "remote.origin.url"]), + UPSTREAM + ); + let updated: Value = serde_json::from_slice(&f.project_bytes()).unwrap(); + assert_eq!(updated, project); + let calls = f.calls(); + assert!(calls.iter().any(|c| c["endpoint"] == "user")); + let post = calls.iter().position(|c| c["method"] == "POST").unwrap(); + assert_eq!(calls[post]["endpoint"], "repos/upstream-org/library/forks"); + assert!( + calls[post + 1..] + .iter() + .filter(|c| c["method"] == "GET" && c["endpoint"] == "repos/contributor/library") + .count() + >= 2 + ); +} + +#[test] +fn adopts_renamed_direct_fork_with_idempotent_post() { + let f = Fixture::new(); + f.settings(json!({"existing": true, "name": "renamed-library"})); + let output = f.run(&["fork", "library"]); + assert_eq!( + output.trim(), + "library: upstream-org/library → contributor/renamed-library (forked), pushes go to the fork" + ); + assert_eq!( + f.pushurl(), + "https://github.com/contributor/renamed-library.git" + ); + assert_eq!(f.post_count(), 1); +} + +#[test] +fn indirect_fork_rejected_without_git_or_project_writes() { + let f = Fixture::new(); + f.settings(json!({"existing": true, "indirect": true})); + let before = (f.config(), f.project_bytes()); + f.fail(&["fork", "library"]); + assert_eq!((f.config(), f.project_bytes()), before); +} + +#[test] +fn ssh_origin_selects_ssh_fork_transport() { + let f = Fixture::new(); + git( + &f.local, + [ + "remote", + "set-url", + "origin", + "git@github.com:upstream-org/library.git", + ], + ); + f.run(&["fork"]); + assert_eq!(f.pushurl(), "git@github.com:contributor/library.git"); +} + +#[test] +fn equal_pushurl_is_noop_and_different_requires_force() { + let f = Fixture::new(); + f.settings(json!({"existing": true})); + git(&f.local, ["config", "remote.origin.pushurl", FORK]); + let before = f.config(); + f.run(&["fork", "library"]); + assert_eq!(f.config(), before); + assert_eq!(f.post_count(), 0); + fs::write(f.api.join("calls"), "").unwrap(); + git( + &f.local, + [ + "config", + "remote.origin.pushurl", + "https://github.com/other/library.git", + ], + ); + let before = (f.config(), f.project_bytes()); + f.fail(&["fork", "library"]); + assert_eq!((f.config(), f.project_bytes()), before); + f.no_posts(); + f.run(&["fork", "library", "--force"]); + assert_eq!(f.pushurl(), FORK); + f.no_posts(); +} + +#[test] +fn multiple_pushurls_are_refused_even_with_force() { + let f = Fixture::new(); + for url in [FORK, "https://github.com/other/library.git"] { + git(&f.local, ["config", "--add", "remote.origin.pushurl", url]); + } + let before = (f.config(), f.project_bytes()); + f.fail(&["fork", "library", "--force"]); + assert_eq!((f.config(), f.project_bytes()), before); + f.no_posts(); +} + +#[test] +fn dry_run_reports_json_without_post_or_local_writes() { + let f = Fixture::new(); + let before = (f.config(), f.project_bytes()); + let output = f.run(&["fork", "library", "--dry-run", "--json"]); + let report: Value = serde_json::from_str(&output).unwrap(); + assert_eq!(report["dryRun"], true); + assert_eq!(report["repos"][0]["status"], "would create"); + assert_eq!((f.config(), f.project_bytes()), before); + f.no_posts(); + f.settings(json!({"existing": true})); + let (stdout, stderr, ok) = f.split(&["fork", "library", "--dry-run", "--json"], &[]); + assert!(ok, "{stderr}"); + assert!( + stderr.is_empty(), + "dry run must not announce creation: {stderr}" + ); + let report: Value = serde_json::from_str(&stdout).unwrap(); + assert_eq!(report["repos"][0]["status"], "exists"); + assert_eq!((f.config(), f.project_bytes()), before); + f.no_posts(); +} + +#[test] +fn organization_owner_is_sent_in_create_payload() { + let f = Fixture::new(); + f.settings(json!({"owner": "contributor-org"})); + f.run(&["fork", "library", "--owner", "contributor-org"]); + assert_eq!( + f.pushurl(), + "https://github.com/contributor-org/library.git" + ); + let calls = f.calls(); + let post = calls.iter().find(|c| c["method"] == "POST").unwrap(); + assert_eq!(post["payload"]["organization"], "contributor-org"); +} + +#[test] +fn unsupported_repo_fails_but_remaining_repo_succeeds() { + let f = Fixture::new(); + let other = f.root.join("other"); + init_repo(&other, "other"); + git( + &other, + [ + "remote", + "add", + "origin", + "https://gitlab.com/upstream-org/other.git", + ], + ); + knit( + &f.workspace, + ["project", "add", "other", other.to_str().unwrap()], + ); + let before = fs::read(other.join(".git/config")).unwrap(); + let (stdout, stderr, ok) = f.split(&["fork", "other", "library", "--json"], &[]); + assert!(!ok, "{stderr}"); + let report: Value = serde_json::from_str(&stdout).unwrap(); + assert_eq!(report["repos"][0]["status"], "error"); + assert!(report["repos"][0]["error"] + .as_str() + .unwrap() + .contains("Knit can only create forks on github.com")); + assert_eq!(report["repos"][1]["status"], "forked"); + assert_eq!(f.pushurl(), FORK); + assert_eq!(fs::read(other.join(".git/config")).unwrap(), before); +} + +#[test] +fn linked_worktree_observes_shared_pushurl() { + let f = Fixture::new(); + let linked = f.root.join("linked"); + git( + &f.local, + ["worktree", "add", "-b", "feature", linked.to_str().unwrap()], + ); + f.run(&["fork", "library"]); + assert_eq!( + git(&linked, ["config", "--get", "remote.origin.pushurl"]), + FORK + ); + assert_eq!( + git(&linked, ["config", "--get", "remote.origin.url"]), + UPSTREAM + ); +} + +fn registered_path(f: &Fixture, id: &str) -> PathBuf { + let project: Value = serde_json::from_slice(&f.project_bytes()).unwrap(); + let repo = project["repos"] + .as_array() + .unwrap() + .iter() + .find(|repo| repo["id"] == id) + .expect("clone remains registered"); + let path = PathBuf::from(repo["path"].as_str().unwrap()); + if path.is_absolute() { + path + } else { + f.workspace.join(path) + } +} + +#[test] +fn project_add_file_url_clones_and_registers_without_github() { + let f = Fixture::new(); + let url = format!("file://{}", f.upstream.display()); + f.run(&["project", "add", "cloned", &url, "--no-fork"]); + let cloned = registered_path(&f, "cloned"); + assert_eq!( + git(&cloned, ["rev-parse", "HEAD"]), + git(&f.local, ["rev-parse", "HEAD"]) + ); + assert_eq!(git(&cloned, ["config", "--get", "remote.origin.url"]), url); + assert!(f.calls().is_empty()); +} + +#[test] +fn project_add_fork_failure_preserves_successful_local_clone() { + let f = Fixture::new(); + let url = format!("file://{}", f.upstream.display()); + let error = f.fail(&["project", "add", "cloned", &url, "--fork"]); + assert!( + error.contains("Knit can only create forks on github.com"), + "{error}" + ); + let cloned = registered_path(&f, "cloned"); + assert_eq!( + git(&cloned, ["rev-parse", "HEAD"]), + git(&f.local, ["rev-parse", "HEAD"]) + ); + assert_eq!(git(&cloned, ["config", "--get", "remote.origin.url"]), url); + f.no_posts(); +} + +#[test] +fn project_add_github_url_wires_fork_with_requested_owner() { + let f = Fixture::new(); + f.settings(json!({"owner": "contributor-org"})); + // Rewrite clone transport locally; fork discovery must use raw origin. + let key = format!("url.file://{}.insteadOf", f.upstream.display()); + knit_with_fake_gh_env( + &f.workspace, + [ + "project", + "add", + "cloned", + UPSTREAM, + "--fork", + "--owner", + "contributor-org", + ], + &f.bin, + &f.api, + &[ + ("KNIT_GITHUB_API_TRANSPORT", "gh"), + ("GIT_CONFIG_COUNT", "1"), + ("GIT_CONFIG_KEY_0", &key), + ("GIT_CONFIG_VALUE_0", UPSTREAM), + ], + ); + let cloned = registered_path(&f, "cloned"); + assert_eq!( + git(&cloned, ["rev-parse", "HEAD"]), + git(&f.local, ["rev-parse", "HEAD"]) + ); + assert_eq!( + git(&cloned, ["config", "--get", "remote.origin.url"]), + UPSTREAM + ); + assert_eq!( + git(&cloned, ["config", "--get", "remote.origin.pushurl"]), + "https://github.com/contributor-org/library.git" + ); +} + +#[test] +fn contribution_discovery_recognizes_new_fork_and_upstream_review_target() { + let f = Fixture::new(); + f.run(&["fork", "library"]); + let mut repo: knit::model::RepoEntry = serde_json::from_value(json!({ + "id": "library", "path": f.local, "remote": UPSTREAM, + "baseBranch": "main", "featureBranch": "contribution", + "headSha": git(&f.local, ["rev-parse", "HEAD"]) + })) + .unwrap(); + assert!(knit::contribution::discover(&f.local, &mut repo).unwrap()); + assert_eq!(repo.source_remote.as_deref(), Some(FORK)); + assert_eq!(repo.target_remote.as_deref(), Some(UPSTREAM)); + assert_eq!( + knit::contribution::head(&repo, "contribution").unwrap(), + "contributor:contribution" + ); + let forge = knit::providers::by_id("github").unwrap(); + let target = + knit::contribution::target(&f.local, &repo, forge.as_ref(), "main", false).unwrap(); + let identity = target.contribution.unwrap(); + assert_eq!(identity.source, "contributor/library"); + assert_eq!(identity.target, "upstream-org/library"); +} + +// Isolate provider environment from other tests in this process. The fake API +// deliberately rejects the final write; reaching it proves read-only publish +// preflight accepted the newly created fork without creating a review. +#[test] +fn publish_preflight_child() { + let Ok(path) = std::env::var("FORK_CREATE_PREFLIGHT_REPO") else { + return; + }; + let local = PathBuf::from(path); + let mut repo: knit::model::RepoEntry = serde_json::from_value(json!({ + "id": "library", "path": local, "remote": UPSTREAM, + "baseBranch": "main", "featureBranch": "contribution", + "headSha": git(&local, ["rev-parse", "HEAD"]) + })) + .unwrap(); + assert!(knit::contribution::discover(&local, &mut repo).unwrap()); + let forge = knit::providers::by_id("github").unwrap(); + let target = knit::contribution::target(&local, &repo, forge.as_ref(), "main", false).unwrap(); + let error = forge + .create( + &target, + "main", + "contributor:contribution", + "Contribution", + "Synthetic test", + false, + ) + .unwrap_err(); + assert!( + format!("{error:#}").contains("synthetic preflight complete"), + "{error:#}" + ); +} + +#[test] +fn publish_preflight_accepts_newly_created_direct_fork() { + let f = Fixture::new(); + f.run(&["fork", "library"]); + let sha = git(&f.local, ["rev-parse", "HEAD"]); + f.settings(json!({"head": sha})); + fs::write(f.api.join("calls"), "").unwrap(); + let original_path = std::env::var_os("PATH").unwrap_or_default(); + let path = std::env::join_paths( + std::iter::once(f.bin.clone()).chain(std::env::split_paths(&original_path)), + ) + .unwrap(); + let output = std::process::Command::new(std::env::current_exe().unwrap()) + .args(["--exact", "publish_preflight_child", "--nocapture"]) + .current_dir(&f.local) + .env("FORK_CREATE_PREFLIGHT_REPO", &f.local) + .env("PATH", path) + .env("GH_FAKE_DIR", &f.api) + .env("KNIT_GITHUB_API_TRANSPORT", "gh") + .env("KNIT_HOME", f.root.join("preflight-home")) + .env_remove("KNIT_BUNDLE") + .env_remove("KNIT_SESSION") + .output() + .unwrap(); + assert!( + output.status.success(), + "{}{}", + String::from_utf8_lossy(&output.stdout), + String::from_utf8_lossy(&output.stderr) + ); + let calls = f.calls(); + for endpoint in [ + "repos/contributor/library", + "repos/upstream-org/library", + "repos/upstream-org/library/git/ref/heads/main", + "repos/contributor/library/git/ref/heads/contribution", + ] { + assert!( + calls + .iter() + .any(|c| c["method"] == "GET" && c["endpoint"] == endpoint), + "missing {endpoint}: {calls:?}" + ); + } + let attempted = calls.iter().find(|c| c["method"] == "POST").unwrap(); + assert_eq!(attempted["endpoint"], "repos/upstream-org/library/pulls"); + assert_eq!(attempted["payload"]["head"], "contributor:contribution"); + assert_eq!(attempted["payload"]["head_repo"], "library"); +} + +#[test] +fn timeout_is_bounded_and_never_writes_configuration() { + let f = Fixture::new(); + f.settings(json!({"never_ready": true})); + let before = (f.config(), f.project_bytes()); + let start = std::time::Instant::now(); + let (_, stderr, ok) = f.split(&["fork", "library"], &[]); + assert!(!ok); + assert!(stderr.contains("timed out"), "{stderr}"); + assert!(start.elapsed() < std::time::Duration::from_secs(10)); + assert_eq!(f.post_count(), 1); + assert_eq!((f.config(), f.project_bytes()), before); +} + +#[test] +fn post_indirect_fork_is_rejected_before_configuration() { + let f = Fixture::new(); + f.settings(json!({"post_indirect": true})); + let before = (f.config(), f.project_bytes()); + let error = f.fail(&["fork", "library"]); + assert!(error.contains("direct fork"), "{error}"); + assert_eq!(f.post_count(), 1); + assert_eq!((f.config(), f.project_bytes()), before); +} + +#[test] +fn human_output_distinguishes_created_from_existing_and_json_agrees() { + let f = Fixture::new(); + assert_eq!( + f.run(&["fork", "library"]).trim(), + "library: upstream-org/library → contributor/library (forked), pushes go to the fork" + ); + assert_eq!( + f.run(&["fork", "library"]).trim(), + "library: upstream-org/library → contributor/library (exists), pushes go to the fork" + ); + let report: Value = serde_json::from_str(&f.run(&["fork", "library", "--json"])).unwrap(); + assert_eq!(report["repos"][0]["status"], "exists"); + assert_eq!(f.post_count(), 1); +} + +#[test] +fn clone_destination_mismatch_refuses_without_changes() { + let f = Fixture::new(); + let destination = f.workspace.join("cloned"); + init_repo(&destination, "unrelated"); + git( + &destination, + [ + "remote", + "add", + "origin", + "https://github.com/other/library.git", + ], + ); + let before = ( + fs::read(destination.join(".git/config")).unwrap(), + f.project_bytes(), + git(&destination, ["rev-parse", "HEAD"]), + ); + let url = format!("file://{}", f.upstream.display()); + let error = f.fail(&["project", "add", "cloned", &url, "--fork"]); + assert!(error.contains("is not a clone of"), "{error}"); + assert_eq!( + ( + fs::read(destination.join(".git/config")).unwrap(), + f.project_bytes(), + git(&destination, ["rev-parse", "HEAD"]) + ), + before + ); + assert!(f.calls().is_empty()); +} + +#[test] +fn same_clone_is_adopted_and_partial_clone_filter_is_preserved() { + let f = Fixture::new(); + git(&f.upstream, ["config", "uploadpack.allowFilter", "true"]); + let url = format!("file://{}", f.upstream.display()); + f.run(&["project", "add", "cloned", &url, "--no-fork"]); + let cloned = registered_path(&f, "cloned"); + assert_eq!( + git(&cloned, ["config", "--get", "remote.origin.promisor"]), + "true" + ); + assert_eq!( + git( + &cloned, + ["config", "--get", "remote.origin.partialclonefilter"] + ), + "blob:none" + ); + fs::write(cloned.join("local-only.txt"), "preserve local work").unwrap(); + let config = fs::read(cloned.join(".git/config")).unwrap(); + f.run(&["project", "add", "cloned", &url, "--no-fork"]); + assert_eq!(registered_path(&f, "cloned"), cloned); + assert_eq!(fs::read(cloned.join(".git/config")).unwrap(), config); + assert_eq!( + fs::read_to_string(cloned.join("local-only.txt")).unwrap(), + "preserve local work" + ); + let project: Value = serde_json::from_slice(&f.project_bytes()).unwrap(); + assert_eq!( + project["repos"] + .as_array() + .unwrap() + .iter() + .filter(|r| r["id"] == "cloned") + .count(), + 1 + ); + assert!(f.calls().is_empty()); +} + +#[test] +fn public_fork_notice_occurs_once_before_first_post_for_multiple_repos() { + let f = Fixture::new(); + let other = f.root.join("second"); + init_repo(&other, "second"); + git(&other, ["remote", "add", "origin", UPSTREAM]); + knit( + &f.workspace, + ["project", "add", "second", other.to_str().unwrap()], + ); + let notice = f.api.join("notice.log"); + let original = std::env::var_os("PATH").unwrap_or_default(); + let path = std::env::join_paths( + std::iter::once(f.bin.clone()).chain(std::env::split_paths(&original)), + ) + .unwrap(); + let output = std::process::Command::new(env!("CARGO_BIN_EXE_knit")) + .args(["fork", "library", "second", "--json"]) + .current_dir(&f.workspace) + .env("PATH", path) + .env("GH_FAKE_DIR", &f.api) + .env("KNIT_HOME", f.root.join("notice-home")) + .env("KNIT_GITHUB_API_TRANSPORT", "gh") + .env("FORK_CREATE_NOTICE_LOG", ¬ice) + .env_remove("KNIT_BUNDLE") + .env_remove("KNIT_SESSION") + .stderr(fs::File::create(¬ice).unwrap()) + .output() + .unwrap(); + let stderr = fs::read_to_string(¬ice).unwrap(); + assert!(output.status.success(), "{stderr}"); + assert_eq!( + stderr + .lines() + .filter(|line| !line.trim().is_empty()) + .count(), + 1, + "{stderr}" + ); + assert!(stderr.to_lowercase().contains("public"), "{stderr}"); + assert_eq!(f.post_count(), 1); + let report: Value = serde_json::from_slice(&output.stdout).unwrap(); + assert_eq!(report["repos"].as_array().unwrap().len(), 2); +} + +fn is_loopback_api(value: &str) -> bool { + let Ok(url) = url::Url::parse(value) else { + return false; + }; + url.scheme() == "http" + && url.host_str() == Some("127.0.0.1") + && url.port().is_some_and(|port| port != 0) + && url.username().is_empty() + && url.password().is_none() + && url.path() == "/" + && url.query().is_none() + && url.fragment().is_none() +} + +struct NativeServer(std::process::Child); +impl Drop for NativeServer { + fn drop(&mut self) { + let _ = self.0.kill(); + let _ = self.0.wait(); + } +} + +fn start_native_server(f: &Fixture) -> (NativeServer, String) { + use std::io::BufRead; + use std::process::{Command, Stdio}; + let script = f.api.join("fork_server.py"); + fs::write(&script, include_str!("fixtures/fork_create_http.py")).unwrap(); + let mut server = NativeServer( + Command::new(python_executable()) + .arg(&script) + .arg(f.bin.join("gh.py")) + .current_dir(&f.workspace) + .env("GH_FAKE_DIR", &f.api) + .env_remove("ELECTRON_RUN_AS_NODE") + .stdout(Stdio::piped()) + .spawn() + .unwrap(), + ); + let mut api = String::new(); + std::io::BufReader::new(server.0.stdout.take().unwrap()) + .read_line(&mut api) + .unwrap(); + let api = api.trim().to_owned(); + assert!( + is_loopback_api(&api), + "loopback fixture did not start: {api:?}" + ); + assert!( + server.0.try_wait().unwrap().is_none(), + "loopback fixture exited" + ); + (server, api) +} + +#[test] +fn native_fixture_rejects_empty_external_and_malformed_api_urls() { + for value in [ + "", + "https://api.github.com", + "http://127.0.0.1:0", + "http://127.0.0.1:123@github.com", + "http://127.0.0.1:123/path", + "http://127.0.0.1:123?redirect=external", + "http://localhost:123", + ] { + assert!(!is_loopback_api(value), "{value}"); + } + assert!(is_loopback_api("http://127.0.0.1:12345")); +} + +#[test] +fn native_api_uses_token_for_creation_and_polling() { + let f = Fixture::new(); + f.settings(json!({"delay_once": true})); + let (_server, api) = start_native_server(&f); + let home = f.root.join("native-home"); + fs::create_dir_all(&home).unwrap(); + let (stdout, stderr, ok) = f.split( + &["fork", "library", "--json"], + &[ + ("KNIT_HOME", home.to_str().unwrap()), + ("KNIT_GITHUB_API_TRANSPORT", "native"), + ("KNIT_GITHUB_API_BASE", api.trim()), + ("GH_TOKEN", "synthetic-upstream-token"), + ("GITHUB_TOKEN", "synthetic-wrong-secondary"), + ], + ); + assert!(ok, "{stdout}{stderr}"); + let report: Value = serde_json::from_str(&stdout).unwrap(); + assert_eq!(report["repos"][0]["status"], "forked"); + assert_eq!(f.pushurl(), FORK); + assert_eq!(f.post_count(), 1); + let calls = f.calls(); + let post = calls.iter().position(|c| c["method"] == "POST").unwrap(); + assert!( + calls[post + 1..] + .iter() + .filter(|c| c["endpoint"] == "repos/contributor/library") + .count() + >= 2 + ); + let headers = fs::read_to_string(f.api.join("headers")).unwrap(); + assert!(!headers.is_empty()); + for line in headers.lines() { + let value: Value = serde_json::from_str(line).unwrap(); + assert_eq!(value["authorization"], "Bearer synthetic-upstream-token"); + } +} + +#[test] +fn saved_upstream_token_overrides_ambient_for_all_gh_requests() { + let f = Fixture::new(); + f.settings(json!({"expected_token": "synthetic-upstream-token", "delay_once": true})); + let home = f.root.join("bound-home"); + fs::create_dir_all(&home).unwrap(); + let project_path = f.workspace.join(".knit/projects/demo.project.json"); + let mut project: Value = serde_json::from_slice(&f.project_bytes()).unwrap(); + project["repos"][0]["remote"] = json!(UPSTREAM); + fs::write(&project_path, serde_json::to_vec(&project).unwrap()).unwrap(); + let key = fs::canonicalize(&project_path) + .unwrap() + .to_string_lossy() + .into_owned(); + fs::write(home.join("forge-auth.json"), serde_json::to_vec(&json!({ + "credentials": {"upstream": {"provider": "github", "host": "github.com", "tokenEnv": "FORK_CREATE_TOKEN"}}, + "scopedCredentials": ["upstream"], "projects": {key: {"library": "upstream"}} + })).unwrap()).unwrap(); + let (stdout, stderr, ok) = f.split( + &["fork", "library", "--json"], + &[ + ("KNIT_HOME", home.to_str().unwrap()), + ("GH_TOKEN", "synthetic-wrong-ambient"), + ("FORK_CREATE_TOKEN", "synthetic-upstream-token"), + ], + ); + assert!(ok, "{stdout}{stderr}"); + assert_eq!(f.pushurl(), FORK); + assert_eq!(f.post_count(), 1); +} + +#[test] +fn inherited_pushurl_force_refuses_include_and_global_before_post() { + for included in [true, false] { + let f = Fixture::new(); + let inherited = f.root.join("inherited.gitconfig"); + fs::write( + &inherited, + "[remote \"origin\"]\n\tpushurl = https://github.com/other/library.git\n", + ) + .unwrap(); + if included { + git( + &f.local, + [ + "config", + "--local", + "include.path", + inherited.to_str().unwrap(), + ], + ); + } + let before = (f.config(), f.project_bytes(), fs::read(&inherited).unwrap()); + let extra = if included { + vec![] + } else { + vec![("GIT_CONFIG_GLOBAL", inherited.to_str().unwrap())] + }; + let (stdout, stderr, ok) = f.split(&["fork", "library", "--force"], &extra); + assert!(!ok, "{stdout}{stderr}"); + assert!(stderr.contains("outside this repository"), "{stderr}"); + f.no_posts(); + assert_eq!( + (f.config(), f.project_bytes(), fs::read(&inherited).unwrap()), + before + ); + } +} + +fn assert_no_pushurl(repo: &Path) { + assert!( + !git(repo, ["config", "--local", "--list"]) + .lines() + .any(|line| line.starts_with("remote.origin.pushurl=")), + "unexpected pushurl in {}", + repo.display() + ); +} + +fn assert_summary(stdout: &str, expected: &str) { + assert_eq!( + stdout.lines().filter(|line| *line == expected).count(), + 1, + "{stdout}" + ); +} + +fn assert_unknown_guidance(stderr: &str, id: &str, upstream: &str) { + let guidance = format!( + "Knit can't tell whether you can push to {upstream}; sign in with `knit auth` or pass --fork or --no-fork" + ); + assert_eq!(stderr.matches(&guidance).count(), 1, "{stderr}"); + assert!(stderr.contains(&format!("{id}: {guidance}")), "{stderr}"); + assert!(!stderr.contains(&format!("{id}: {id}:")), "{stderr}"); +} + +#[test] +fn project_add_url_auto_uses_upstream_push_permission() { + for push in [true, false] { + let f = Fixture::new(); + f.settings(json!({"push": push})); + let (stdout, stderr, ok) = f.clone_split(&["project", "add", "cloned", UPSTREAM]); + assert!(ok, "{stdout}{stderr}"); + let cloned = registered_path(&f, "cloned"); + assert_eq!( + git(&cloned, ["config", "--get", "remote.origin.url"]), + UPSTREAM + ); + assert!(f + .calls() + .iter() + .any(|c| c["method"] == "GET" && c["endpoint"] == "repos/upstream-org/library")); + if push { + assert_summary(&stdout, "cloned: direct (you can push)"); + f.no_posts(); + assert_no_pushurl(&cloned); + } else { + assert_summary(&stdout, "cloned: via fork contributor/library"); + assert_eq!(f.post_count(), 1); + assert_eq!( + git(&cloned, ["config", "--get", "remote.origin.pushurl"]), + FORK + ); + } + } +} + +#[test] +fn project_add_url_unknown_permission_stays_added_with_explicit_choices() { + let f = Fixture::new(); + let (stdout, stderr, ok) = f.clone_split(&["project", "add", "cloned", UPSTREAM]); + assert!(!ok, "{stdout}{stderr}"); + assert_unknown_guidance(&stderr, "cloned", "upstream-org/library"); + let cloned = registered_path(&f, "cloned"); + assert_eq!( + git(&cloned, ["rev-parse", "HEAD"]), + git(&f.local, ["rev-parse", "HEAD"]) + ); + assert_no_pushurl(&cloned); + f.no_posts(); + assert!(!f.calls().is_empty()); +} + +#[test] +fn project_add_no_fork_skips_all_forge_calls() { + let f = Fixture::new(); + f.settings(json!({"push": false})); + let (stdout, stderr, ok) = f.clone_split(&["project", "add", "cloned", UPSTREAM, "--no-fork"]); + assert!(ok, "{stdout}{stderr}"); + assert_summary(&stdout, "cloned: direct (--no-fork)"); + assert_no_pushurl(®istered_path(&f, "cloned")); + assert!(f.calls().is_empty()); +} + +#[test] +fn project_add_explicit_fork_overrides_push_permission() { + let f = Fixture::new(); + f.settings(json!({"push": true})); + let (stdout, stderr, ok) = f.clone_split(&["project", "add", "cloned", UPSTREAM, "--fork"]); + assert!(ok, "{stdout}{stderr}"); + assert_eq!(f.post_count(), 1); + assert_eq!( + git( + ®istered_path(&f, "cloned"), + ["config", "--get", "remote.origin.pushurl"] + ), + FORK + ); +} + +#[test] +fn project_add_local_defaults_to_never_even_without_push_permission() { + let f = Fixture::new(); + f.settings(json!({"push": false})); + f.run(&["project", "add", "local-copy", f.local.to_str().unwrap()]); + assert_eq!( + fs::canonicalize(registered_path(&f, "local-copy")).unwrap(), + fs::canonicalize(&f.local).unwrap() + ); + assert!(f.calls().is_empty()); + assert_no_pushurl(&f.local); +} + +#[test] +fn fork_flags_conflict_before_project_or_clone_creation() { + for flags in [ + vec!["--fork", "--no-fork"], + vec!["--owner", "contributor-org", "--no-fork"], + ] { + for init in [false, true] { + let f = Fixture::fresh_workspace(); + let mut args = if init { + vec!["init", "demo", "upstream-org/library"] + } else { + vec!["project", "add", "cloned", UPSTREAM] + }; + args.extend(flags.iter().copied()); + let (stdout, stderr, ok) = f.clone_split(&args); + assert!(!ok, "{stdout}{stderr}"); + assert!(stderr.contains("--no-fork"), "{stderr}"); + assert!(stderr.contains("cannot be used with"), "{stderr}"); + assert!(!f.workspace.join(".knit").exists()); + assert!(!f.workspace.join("cloned").exists()); + assert!(f.calls().is_empty()); + } + } +} + +#[test] +fn init_repo_references_accept_shorthand_url_and_slugged_explicit_id() { + for (reference, id) in [ + ("upstream-org/library", "library"), + (UPSTREAM, "library"), + ("Client Library=upstream-org/library", "client-library"), + ( + "client-library=https://github.com/upstream-org/library.git", + "client-library", + ), + ] { + let f = Fixture::fresh_workspace(); + f.settings(json!({"push": true})); + let (stdout, stderr, ok) = f.clone_split(&["init", "Demo", reference]); + assert!(ok, "{reference}: {stdout}{stderr}"); + let repo = registered_path(&f, id); + assert_eq!( + git(&repo, ["rev-parse", "HEAD"]), + git(&f.local, ["rev-parse", "HEAD"]) + ); + assert_no_pushurl(&repo); + f.no_posts(); + assert!(!f.calls().is_empty()); + let project: Value = serde_json::from_slice(&f.project_bytes()).unwrap(); + assert_eq!(project["repos"].as_array().unwrap().len(), 1); + } +} + +#[test] +fn init_multiple_repos_adds_both_and_reports_in_argument_order() { + for second_push in [true, false] { + let f = Fixture::fresh_workspace(); + init_remote_repo(&f.root, "second"); + f.settings(json!({"repos": {"library": {"push": false}, "second": {"push": second_push}}})); + let (stdout, stderr, ok) = f.clone_split(&[ + "init", + "demo", + "upstream-org/library", + "https://github.com/upstream-org/second.git", + "--agents", + ]); + assert!(ok, "{stdout}{stderr}"); + let library = registered_path(&f, "library"); + let second = registered_path(&f, "second"); + assert_eq!( + git(&library, ["config", "--get", "remote.origin.pushurl"]), + FORK + ); + if second_push { + assert_no_pushurl(&second); + } else { + assert_eq!( + git(&second, ["config", "--get", "remote.origin.pushurl"]), + "https://github.com/contributor/second.git" + ); + } + assert_eq!(f.post_count(), if second_push { 1 } else { 2 }); + let first = stdout.find("library:").expect(&stdout); + let second_summary = stdout.find("second:").expect(&stdout); + assert!(first < second_summary, "{stdout}"); + assert_summary(&stdout, "library: via fork contributor/library"); + assert_summary( + &stdout, + if second_push { + "second: direct (you can push)" + } else { + "second: via fork contributor/second" + }, + ); + let agents = fs::read_to_string(f.workspace.join("AGENTS.md")).unwrap(); + assert!(agents.lines().any(|line| line == "- `library`"), "{agents}"); + assert!(agents.lines().any(|line| line == "- `second`"), "{agents}"); + let project: Value = serde_json::from_slice(&f.project_bytes()).unwrap(); + assert_eq!(project["repos"].as_array().unwrap().len(), 2); + } +} + +#[test] +fn init_unknown_permission_keeps_repo_and_processes_following_repo() { + let f = Fixture::fresh_workspace(); + init_remote_repo(&f.root, "second"); + f.settings(json!({"repos": {"second": {"push": false}}})); + let (stdout, stderr, ok) = f.clone_split(&[ + "init", + "demo", + "upstream-org/library", + "upstream-org/second", + ]); + assert!(!ok, "{stdout}{stderr}"); + assert_unknown_guidance(&stderr, "library", "upstream-org/library"); + assert_no_pushurl(®istered_path(&f, "library")); + assert_eq!( + git( + ®istered_path(&f, "second"), + ["config", "--get", "remote.origin.pushurl"] + ), + "https://github.com/contributor/second.git" + ); + assert_eq!(f.post_count(), 1); + assert_summary(&stdout, "second: via fork contributor/second"); +} + +#[test] +fn init_no_fork_skips_forge_and_explicit_fork_honors_owner() { + for force_fork in [false, true] { + let f = Fixture::fresh_workspace(); + f.settings(json!({"push": true, "owner": "contributor-org"})); + let mut args = vec!["init", "demo", "upstream-org/library"]; + if force_fork { + args.extend(["--fork", "--owner", "contributor-org"]); + } else { + args.push("--no-fork"); + } + let (stdout, stderr, ok) = f.clone_split(&args); + assert!(ok, "{stdout}{stderr}"); + let repo = registered_path(&f, "library"); + if force_fork { + assert_eq!( + git(&repo, ["config", "--get", "remote.origin.pushurl"]), + "https://github.com/contributor-org/library.git" + ); + let calls = f.calls(); + let post = calls.iter().find(|c| c["method"] == "POST").unwrap(); + assert_eq!(post["payload"]["organization"], "contributor-org"); + } else { + assert!(f.calls().is_empty()); + assert_no_pushurl(&repo); + } + } +} + +#[test] +fn init_invalid_references_and_duplicate_ids_fail_before_creating_anything() { + for refs in [ + vec!["upstream-org/library", "other-org/library"], + vec![ + "Client Library=upstream-org/library", + "client-library=upstream-org/second", + ], + vec!["upstream-org/library", "invalid-reference"], + vec!["upstream-org/library", "=upstream-org/second"], + vec!["upstream-org/library", "second="], + vec!["upstream-org/library", "owner/name/extra"], + vec!["upstream-org/library", "https://"], + vec!["upstream-org/library", "https://github.com"], + ] { + let f = Fixture::fresh_workspace(); + let mut args = vec!["init", "demo"]; + args.extend(refs.iter().copied()); + let (stdout, stderr, ok) = f.clone_split(&args); + assert!(!ok, "{refs:?}: {stdout}{stderr}"); + assert!( + !f.workspace.join(".knit").exists(), + "{refs:?}: {stdout}{stderr}" + ); + assert_eq!(fs::read_dir(&f.workspace).unwrap().count(), 0); + assert!(f.calls().is_empty()); + } +} + +#[test] +fn plain_init_still_creates_empty_project_without_forge_calls() { + let f = Fixture::fresh_workspace(); + f.run(&["init", "demo"]); + let project: Value = serde_json::from_slice(&f.project_bytes()).unwrap(); + assert!(project["repos"].as_array().unwrap().is_empty()); + assert!(f.calls().is_empty()); + assert!(!f.workspace.join("library").exists()); +} + +#[test] +fn project_add_auto_unsupported_forge_keeps_clone_without_forge_calls() { + let f = Fixture::new(); + let url = format!("file://{}", f.upstream.display()); + let (stdout, stderr, ok) = f.split(&["project", "add", "cloned", &url], &[]); + assert!(!ok, "{stdout}{stderr}"); + assert_unknown_guidance(&stderr, "cloned", "cloned"); + let cloned = registered_path(&f, "cloned"); + assert_eq!(git(&cloned, ["config", "--get", "remote.origin.url"]), url); + assert_eq!( + git(&cloned, ["rev-parse", "HEAD"]), + git(&f.local, ["rev-parse", "HEAD"]) + ); + assert_no_pushurl(&cloned); + assert!(f.calls().is_empty()); +} + +#[test] +fn project_add_auto_missing_credentials_preserves_clone_with_guidance() { + let f = Fixture::new(); + f.settings(json!({"missing_credentials": true})); + let (stdout, stderr, ok) = f.clone_split(&["project", "add", "cloned", UPSTREAM]); + assert!(!ok, "{stdout}{stderr}"); + assert_unknown_guidance(&stderr, "cloned", "upstream-org/library"); + assert!(stderr.contains("gh auth login"), "{stderr}"); + assert_no_pushurl(®istered_path(&f, "cloned")); + assert!(!f.calls().is_empty()); + f.no_posts(); +} + +#[test] +fn init_failed_first_clone_still_adds_and_summarizes_following_repo() { + let f = Fixture::fresh_workspace(); + f.settings(json!({"push": true})); + let (stdout, stderr, ok) = f.clone_split(&[ + "init", + "demo", + "upstream-org/missing", + "upstream-org/library", + ]); + assert!(!ok, "{stdout}{stderr}"); + assert!(stderr.contains("missing:"), "{stderr}"); + assert_summary(&stdout, "library: direct (you can push)"); + let project: Value = serde_json::from_slice(&f.project_bytes()).unwrap(); + assert_eq!(project["repos"].as_array().unwrap().len(), 1); + assert_eq!(project["repos"][0]["id"], "library"); + assert_no_pushurl(®istered_path(&f, "library")); + assert!(!f.workspace.join("missing").exists()); + assert!(f + .calls() + .iter() + .all(|call| !call["endpoint"].as_str().unwrap().contains("missing"))); + f.no_posts(); +} + +#[test] +fn auto_fork_accepts_owner_without_explicit_fork_flag() { + for init in [false, true] { + let f = if init { + Fixture::fresh_workspace() + } else { + Fixture::new() + }; + f.settings(json!({"push": false, "owner": "contributor-org"})); + let mut args = if init { + vec!["init", "demo", "upstream-org/library"] + } else { + vec!["project", "add", "library-copy", UPSTREAM] + }; + args.extend(["--owner", "contributor-org"]); + let (stdout, stderr, ok) = f.clone_split(&args); + assert!(ok, "{stdout}{stderr}"); + let id = if init { "library" } else { "library-copy" }; + assert_summary(&stdout, &format!("{id}: via fork contributor-org/library")); + assert_eq!( + git( + ®istered_path(&f, id), + ["config", "--get", "remote.origin.pushurl"] + ), + "https://github.com/contributor-org/library.git" + ); + assert_eq!(f.post_count(), 1); + let calls = f.calls(); + let post = calls.iter().find(|call| call["method"] == "POST").unwrap(); + assert_eq!(post["payload"]["organization"], "contributor-org"); + } +} + +fn hosted_export(ids: &[&str]) -> String { + let url = |id: &str| format!("https://github.com/upstream-org/{id}.git"); + let membership: Vec<Value> = ids + .iter() + .map(|id| json!({"id": id, "path": "", "remote": url(id), "baseBranch": "main"})) + .collect(); + let repositories: Vec<Value> = ids + .iter() + .map(|id| { + json!({"localId": id, "name": id, "remoteUrl": url(id), "defaultBranch": "main", + "visibility": "public", "metadata": {}}) + }) + .collect(); + json!({"data": { + "project": {"slug": "demo"}, + "knitProject": {"schemaVersion": "0.1", "kind": "KnitProject", "id": "demo", + "createdAt": "2026-01-01T00:00:00.000Z", + "updatedAt": "2026-01-01T00:00:00.000Z", "repos": membership}, + "repositories": repositories, + "omittedRepositoryCount": 0, + "bundles": [], + "historyEvents": [], + }}) + .to_string() +} + +fn clone_hosted(f: &Fixture, extra: &[&str]) -> (Value, String, PathBuf) { + let base_url = spawn_fake_remote_with_body(hosted_export(&["library"])); + let target = f.root.join("contributor-checkout"); + let mut args = vec![ + "clone", + "acme/demo", + target.to_str().unwrap(), + "--remote", + "hosted", + "--url", + &base_url, + "--no-worktree", + "--json", + ]; + args.extend_from_slice(extra); + let (stdout, stderr, ok) = f.clone_split(&args); + assert!(ok, "{stdout}{stderr}"); + let document = serde_json::from_str(&stdout).unwrap_or_else(|_| panic!("{stdout}")); + (document, stderr, target) +} + +fn cloned_project(target: &Path) -> Value { + serde_json::from_slice(&fs::read(target.join(".knit/projects/demo.project.json")).unwrap()) + .unwrap() +} + +#[test] +fn clone_forks_only_repositories_you_cannot_push_to() { + for push in [true, false] { + let f = Fixture::fresh_workspace(); + f.settings(json!({"push": push})); + let (document, _, target) = clone_hosted(&f, &[]); + let checkout = target.join("library"); + let expected = if push { + json!({"id": "library", "status": "cloned", "push": "direct"}) + } else { + json!({"id": "library", "status": "cloned", "push": "fork", + "fork": "contributor/library"}) + }; + assert_eq!(document["repos"], json!([expected])); + if push { + assert_no_pushurl(&checkout); + f.no_posts(); + } else { + assert_eq!( + git(&checkout, ["config", "--get", "remote.origin.pushurl"]), + FORK + ); + assert_eq!(f.post_count(), 1); + } + // The fork is this person's push target only: the project, and so + // what `knit sync push` uploads, keeps the upstream fetch URL. + assert_eq!( + git(&checkout, ["config", "--get", "remote.origin.url"]), + UPSTREAM + ); + assert_eq!(cloned_project(&target)["repos"][0]["remote"], UPSTREAM); + } +} + +#[test] +fn clone_with_unknown_permission_keeps_the_checkout_and_names_knit_fork() { + let f = Fixture::fresh_workspace(); + f.settings(json!({"missing_credentials": true})); + let (document, stderr, target) = clone_hosted(&f, &[]); + assert_eq!( + document["repos"], + json!([{"id": "library", "status": "cloned", "push": "unknown"}]) + ); + assert!( + stderr.contains( + "library: Knit can't tell whether you can push to upstream-org/library. If you can't, run `knit fork library`." + ), + "{stderr}" + ); + assert!(target.join("library/app.txt").exists()); + assert_no_pushurl(&target.join("library")); + f.no_posts(); +} + +#[test] +fn clone_no_fork_skips_the_forge_and_fork_honors_owner() { + let f = Fixture::fresh_workspace(); + f.settings(json!({"push": false})); + let (document, _, target) = clone_hosted(&f, &["--no-fork"]); + assert_eq!(document["repos"][0]["push"], "direct"); + assert_no_pushurl(&target.join("library")); + assert!(f.calls().is_empty()); + + let f = Fixture::fresh_workspace(); + f.settings(json!({"push": true, "owner": "contributor-org"})); + let (document, _, target) = clone_hosted(&f, &["--fork", "--owner", "contributor-org"]); + assert_eq!(document["repos"][0]["fork"], "contributor-org/library"); + assert_eq!( + git( + &target.join("library"), + ["config", "--get", "remote.origin.pushurl"] + ), + "https://github.com/contributor-org/library.git" + ); + let calls = f.calls(); + let post = calls.iter().find(|call| call["method"] == "POST").unwrap(); + assert_eq!(post["payload"]["organization"], "contributor-org"); +} + +#[test] +fn clone_takes_the_member_not_a_contributors_fork_the_server_also_lists() { + let f = Fixture::fresh_workspace(); + f.settings(json!({"push": true})); + let mut export: Value = serde_json::from_str(&hosted_export(&["library"])).unwrap(); + export["data"]["repositories"] + .as_array_mut() + .unwrap() + .insert( + 0, + json!({"localId": "library", "name": "library", + "remoteUrl": "https://github.com/contributor/library.git", + "defaultBranch": "main", "visibility": "public", "metadata": {}}), + ); + let base_url = spawn_fake_remote_with_body(export.to_string()); + let target = f.root.join("maintainer-checkout"); + let (stdout, stderr, ok) = f.clone_split(&[ + "clone", + "acme/demo", + target.to_str().unwrap(), + "--remote", + "hosted", + "--url", + &base_url, + "--no-worktree", + "--json", + ]); + assert!(ok, "{stdout}{stderr}"); + let repos = cloned_project(&target)["repos"].clone(); + assert_eq!(repos.as_array().unwrap().len(), 1, "{repos}"); + assert_eq!(repos[0]["remote"], UPSTREAM); + assert_eq!( + git(&target.join("library"), ["remote", "get-url", "origin"]), + UPSTREAM + ); +} + +#[test] +fn sync_pull_forks_new_members_and_leaves_existing_checkouts_alone() { + let f = Fixture::new(); + init_remote_repo(&f.root, "second"); + f.settings(json!({"repos": {"library": {"push": false}, "second": {"push": false}}})); + let base_url = spawn_fake_remote_with_body(hosted_export(&["library", "second"])); + f.run(&["remote", "add", "hosted", &base_url]); + let key = format!("url.file://{}/.insteadOf", f.root.display()); + let (stdout, stderr, ok) = f.split( + &["sync", "pull", "--bundles", "--repos"], + &[ + ("GIT_CONFIG_COUNT", "1"), + ("GIT_CONFIG_KEY_0", &key), + ("GIT_CONFIG_VALUE_0", "https://github.com/upstream-org/"), + ("KNIT_REMOTE_TOKEN", "test-token"), + ], + ); + assert!(ok, "{stdout}{stderr}"); + assert_summary(&stdout, "second: via fork contributor/second"); + assert_eq!( + git( + ®istered_path(&f, "second"), + ["config", "--get", "remote.origin.pushurl"] + ), + "https://github.com/contributor/second.git" + ); + assert_no_pushurl(&f.local); + assert!(f + .calls() + .iter() + .all(|call| !call["endpoint"].as_str().unwrap().contains("/library"))); +}