Skip to content

Commit 8d60043

Browse files
committed
feat(clone): add git-like clone command and docs
1 parent f26df8c commit 8d60043

9 files changed

Lines changed: 163 additions & 1 deletion

File tree

‎docs/commands/clone.md‎

Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,41 @@
1+
# f clone
2+
3+
Clone a repository with git-like destination behavior.
4+
5+
## Overview
6+
7+
`f clone` behaves like `git clone` for destination paths:
8+
9+
- `f clone <url>` clones into the current working directory using Git's default folder naming.
10+
- `f clone <url> <dir>` clones into an explicit destination directory.
11+
12+
For GitHub inputs, Flow normalizes clone URLs to SSH:
13+
14+
- `https://github.com/owner/repo` -> `git@github.com:owner/repo.git`
15+
- `owner/repo` -> `git@github.com:owner/repo.git`
16+
17+
This command does not force clones into `~/repos` and does not auto-configure `upstream`.
18+
19+
## Usage
20+
21+
```bash
22+
f clone <url-or-owner/repo> [directory]
23+
```
24+
25+
## Examples
26+
27+
```bash
28+
# GitHub URL -> SSH clone URL
29+
f clone https://github.com/genxai/new
30+
31+
# owner/repo shorthand -> SSH clone URL
32+
f clone genxai/new
33+
34+
# explicit destination folder (same as git clone)
35+
f clone genxai/new my-local-new
36+
```
37+
38+
## When To Use
39+
40+
- Use `f clone` when you want standard `git clone` destination behavior.
41+
- Use [`f repos clone`](repos.md) when you want managed placement under `~/repos/<owner>/<repo>` plus optional upstream automation.

‎docs/commands/readme.md‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ Complete documentation for all `f` (flow) commands.
1010
| [`release`](release.md) | Publish a release to registry or GitHub |
1111
| [`publish`](publish.md) | Publish project to GitHub |
1212
| [`install`](install.md) | Install a CLI/tool via registry, parm, or flox |
13+
| [`clone`](clone.md) | Clone repositories with git-like destination behavior |
1314
| [`repos`](repos.md) | Clone repositories into ~/repos |
1415
| [`new`](new.md) | Create a project from a local template in ~/new |
1516
| [`commit`](commit.md) | AI-powered commit with code review |
@@ -53,6 +54,7 @@ f commit --help
5354

5455
- **[commit](commit.md)** - AI-powered commits with review
5556
- **[pr](pr.md)** - PR creation/editing plus review feedback ingestion
57+
- **[clone](clone.md)** - Clone with git-like destination behavior
5658
- **[repos](repos.md)** - Clone repos into a structured directory
5759
- **[upstream](upstream.md)** - Fork management and sync
5860
- **[fixup](fixup.md)** - Fix common TOML syntax errors

‎docs/commands/repos.md‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,8 @@
22

33
Clone repositories into a structured local directory or create new ones.
44

5+
If you want standard `git clone` destination behavior, use [`f clone`](clone.md) instead.
6+
57
## Overview
68

79
`f repos clone` clones GitHub repositories into `~/repos/<owner>/<repo>` using SSH URLs. By default it does a shallow clone for speed, then fetches full history in the background. It always sets up an `upstream` remote and local tracking branch unless you pass `--no-upstream`.

‎docs/index.mdx‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@ High-signal docs for the Codex/Claude workflow:
1515
- [`ci-cd-runbook.md`](ci-cd-runbook.md): CI/CD architecture, runner-mode operations, and failure debug checklist for canary/release pipelines.
1616
- [`rise.md`](rise.md): full integration guide for installing and operating Rise via Flow (`f install rise`) across adopt/sync/dev/mobile/schema/sandbox workflows.
1717
- [`commands/domains.md`](commands/domains.md): shared local `*.localhost` proxy ownership via `f domains` (prevents per-repo port-80 collisions).
18+
- [`commands/clone.md`](commands/clone.md): git-like cloning (`f clone`) with GitHub URL/shorthand normalization to SSH, without forcing `~/repos`.
1819
- [`commands/up.md`](commands/up.md): one-command project startup (`f up`) with optional lifecycle domain setup.
1920
- [`commands/down.md`](commands/down.md): one-command project shutdown (`f down`) with lifecycle teardown rules.
2021
- [`commands/reviews-todo.md`](commands/reviews-todo.md): fast-commit + deferred Codex deep-review backlog workflow.

‎docs/moving-repos.md‎

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,18 @@ Flow uses three managed roots:
1414

1515
## Cloning Into the Right Place
1616

17+
### `f clone`
18+
19+
Clones with git-like destination behavior from your current working directory:
20+
21+
```bash
22+
f clone owner/repo
23+
f clone https://github.com/owner/repo
24+
f clone owner/repo local-folder
25+
```
26+
27+
GitHub inputs are normalized to SSH URLs, but destination behavior matches `git clone` (no forced `~/repos` root).
28+
1729
### `f repos clone`
1830

1931
Clones GitHub repos into `~/repos/<owner>/<repo>`:
@@ -189,6 +201,7 @@ f code move-sessions --from ~/code/old --to ~/code/new
189201

190202
## See Also
191203

204+
- [commands/clone.md](commands/clone.md) — `f clone` (git-like destination behavior)
192205
- [commands/repos.md](commands/repos.md) — `f repos clone` / `f repos create`
193206
- [commands/migrate.md](commands/migrate.md) — `f migrate` full reference
194207
- [run-repos.md](run-repos.md) — run repo shortcuts

‎src/cli.rs‎

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -512,6 +512,11 @@ pub enum Commands {
512512
long_about = "Publish the current project. Without a subcommand, shows a fuzzy picker to choose the target."
513513
)]
514514
Publish(PublishCommand),
515+
#[command(
516+
about = "Clone a repository into the current directory (git clone style).",
517+
long_about = "Clones into the current working directory by default, matching git clone destination behavior. GitHub inputs are normalized to SSH URLs."
518+
)]
519+
Clone(CloneOpts),
515520
#[command(
516521
about = "Clone repositories into a structured local directory.",
517522
long_about = "Clone repositories into ~/repos/<owner>/<repo> with SSH URLs and optional upstream setup for forks."
@@ -3233,6 +3238,14 @@ pub struct PublishCommand {
32333238
pub action: Option<PublishAction>,
32343239
}
32353240

3241+
#[derive(Args, Debug, Clone)]
3242+
pub struct CloneOpts {
3243+
/// Repository URL or owner/repo.
3244+
pub url: String,
3245+
/// Optional destination directory (same as git clone <url> <dir>).
3246+
pub directory: Option<String>,
3247+
}
3248+
32363249
#[derive(Subcommand, Debug, Clone)]
32373250
pub enum PublishAction {
32383251
/// Publish to gitedit.dev.

‎src/main.rs‎

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -540,6 +540,9 @@ fn main() -> Result<()> {
540540
Some(Commands::Publish(cmd)) => {
541541
publish::run(cmd)?;
542542
}
543+
Some(Commands::Clone(opts)) => {
544+
repos::clone_git_like(opts)?;
545+
}
543546
Some(Commands::Repos(cmd)) => {
544547
repos::run(cmd)?;
545548
}

‎src/repos.rs‎

Lines changed: 87 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,7 @@ use anyhow::{Context, Result, bail};
1111
use serde::Deserialize;
1212
use url::Url;
1313

14-
use crate::cli::{ReposAction, ReposCloneOpts, ReposCommand};
14+
use crate::cli::{CloneOpts, ReposAction, ReposCloneOpts, ReposCommand};
1515
use crate::{config, publish, ssh, ssh_keys, upstream, vcs};
1616

1717
const DEFAULT_REPOS_ROOT: &str = "~/repos";
@@ -30,6 +30,43 @@ pub fn run(cmd: ReposCommand) -> Result<()> {
3030
}
3131
}
3232

33+
/// Clone into the current working directory (git clone style destination behavior).
34+
pub fn clone_git_like(opts: CloneOpts) -> Result<()> {
35+
ssh::ensure_ssh_env();
36+
let mode = ssh::ssh_mode();
37+
if matches!(mode, ssh::SshMode::Force) && !ssh::has_identities() {
38+
match ssh_keys::ensure_default_identity(24) {
39+
Ok(()) => {}
40+
Err(err) => {
41+
bail!(
42+
"SSH mode is forced but no key is available. Run `f ssh setup` or `f ssh unlock` (error: {})",
43+
err
44+
);
45+
}
46+
}
47+
}
48+
49+
let clone_url = resolve_git_like_clone_url(&opts.url)?;
50+
let mut cmd = Command::new("git");
51+
cmd.arg("clone").arg(&clone_url);
52+
if let Some(dir) = opts.directory {
53+
cmd.arg(dir);
54+
}
55+
56+
let status = cmd
57+
.stdin(Stdio::inherit())
58+
.stdout(Stdio::inherit())
59+
.stderr(Stdio::inherit())
60+
.status()
61+
.context("failed to run git clone")?;
62+
63+
if !status.success() {
64+
bail!("git clone failed");
65+
}
66+
67+
Ok(())
68+
}
69+
3370
fn open_in_zed(path: &std::path::Path) -> Result<()> {
3471
std::process::Command::new("open")
3572
.args(["-a", "/Applications/Zed Preview.app"])
@@ -438,6 +475,55 @@ fn parse_repo_target(input: &str) -> Result<RepoTarget> {
438475
Ok(RepoTarget::Generic(generic))
439476
}
440477

478+
fn resolve_git_like_clone_url(input: &str) -> Result<String> {
479+
let trimmed = input.trim();
480+
if trimmed.is_empty() {
481+
bail!("missing repository URL");
482+
}
483+
484+
if trimmed.starts_with("git@github.com:")
485+
|| trimmed.contains("github.com/")
486+
|| looks_like_github_shorthand(trimmed)
487+
{
488+
let repo_ref = parse_github_repo(trimmed)?;
489+
return Ok(format!(
490+
"git@github.com:{}/{}.git",
491+
repo_ref.owner, repo_ref.repo
492+
));
493+
}
494+
495+
Ok(trimmed.to_string())
496+
}
497+
498+
fn looks_like_github_shorthand(input: &str) -> bool {
499+
if input.contains("://")
500+
|| input.contains('@')
501+
|| input.starts_with('/')
502+
|| input.starts_with("./")
503+
|| input.starts_with("../")
504+
|| input.starts_with("~/")
505+
{
506+
return false;
507+
}
508+
509+
let mut parts = input.split('/');
510+
let Some(owner) = parts.next() else {
511+
return false;
512+
};
513+
let Some(repo) = parts.next() else {
514+
return false;
515+
};
516+
if parts.next().is_some() {
517+
return false;
518+
}
519+
520+
if owner.is_empty() || repo.is_empty() {
521+
return false;
522+
}
523+
524+
owner != "." && owner != ".." && repo != "." && repo != ".."
525+
}
526+
441527
fn is_github_input(input: &str) -> bool {
442528
let trimmed = input.trim();
443529
if trimmed.starts_with("git@github.com:") || trimmed.contains("github.com/") {

‎src/usage.rs‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -318,6 +318,7 @@ fn command_path(raw_args: &[String]) -> String {
318318
"deploy",
319319
"prod",
320320
"publish",
321+
"clone",
321322
"repos",
322323
"code",
323324
"migrate",

0 commit comments

Comments
 (0)