Skip to content

feat: add a project by cloning a git repository - #183

Merged
matej21 merged 14 commits into
mainfrom
feat/clone-project-from-git
Aug 18, 2026
Merged

feat: add a project by cloning a git repository#183
matej21 merged 14 commits into
mainfrom
feat/clone-project-from-git

Conversation

@matej21

@matej21 matej21 commented Aug 14, 2026

Copy link
Copy Markdown
Member

Add a project straight from a repository URL: pick a parent directory, paste the URL, and Okena clones it and registers the checkout as a project.

Surfaces

GUI — the Add Project dialog gains a Source: Folder | Git toggle. Git mode asks for the URL, the parent to clone into, and the folder name to create inside it. The folder name is derived from the URL but stays editable, because the name a repository ships with is not always the one you want on disk; editing it carries the project name along until you give the project a name of its own. Works against remote connections too — it travels the same path as AddProject.

Source:          [Folder] [ Git ]
Repository URL:  https://github.com/user/okena.git
Clone into:      ~/projects/oss            [Browse...]
Folder name:     okena          <- from the URL, editable
Name:            okena
                          [Cancel]  [Clone]

CLIokena project clone <url> [--into <parent>] [--dir <name>] [--name <n>] [--hidden] [--folder <f>]. --into defaults to the CWD, --dir to the name git would pick.

How it runs

The clone follows the CreateWorktree pattern: the daemon registers an optimistic project row (no layout, so the column renders a "Cloning repository…" placeholder), runs git clone on a blocking thread with no workspace lock held, then seeds the layout, fires on_project_open and spawns terminals under a brief lock. A failure rolls the row back and toasts. Running it through the synchronous execute_action path instead would hold the lock for the whole fetch and stall every other daemon action.

git clone runs on Lane::Long — it is network-bound and unbounded, so it must never occupy an interactive or poller slot.

The parent and the directory name travel over the wire separately so the receiving host joins them; a client cannot predict a remote daemon's path shape. The directory stays a name — separators and .. are rejected, so the checkout cannot land outside the parent the user picked.

Verification

Unit and integration tests: URL-to-directory derivation, clone-target validation, the pending-project lifecycle (register → finish / roll back), and three tests driving the real action against a real local repository.

Also exercised end to end against a real daemon in an isolated XDG_CONFIG_HOME + XDG_RUNTIME_DIR:

Scenario Result
project clone <repo> cloned, project listed, terminal started
--dir renamed --name "My Clone" both overrides honored
nonexistent repo row rolled back, no directory left, error logged
--dir ../escape rejected up front, before any network access
URL --upload-pack=evil rejected as an invalid repository URL

cargo test --workspace and cargo clippy --workspace --all-targets are clean on this base.

Incidental

The path-completion list in the dialog now anchors to the input's painted bounds instead of a hardcoded offset — the offset was already approximate, and Git mode's extra rows would have put the list in the wrong place. The creating placeholder also tells a clone from a worktree, so a cloning project no longer claims to be setting up a worktree.

🤖 Generated with Claude Code

https://claude.ai/code/session_01S63Q5kXgrw85F85QS1cntR

@matej21
matej21 force-pushed the feat/clone-project-from-git branch from 6fb3322 to 61fc6ed Compare August 18, 2026 09:51
matej21 and others added 13 commits August 18, 2026 15:11
`clone_repository` runs `git clone -- <url> <target>` on `Lane::Long`. The
lane matters: a clone is network-bound and unbounded in duration, so on the
interactive lane it would sit on one of four slots for however long the repo
takes to fetch, and on the poll lane it would starve git status.

`clone_dir_name` derives the directory git itself would create, so callers
can prefill it. It drops the scheme and host before taking the last path
segment — otherwise a hostless `https://` yields "https" as the name.

Two guards up front, so bad input fails fast with our message instead of a
confusing git one: `validate_clone_url` rejects empty and option-like URLs
(the `--` separator already makes them harmless, but a clear error beats a
silent oddity), and a non-empty target is refused before git runs.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01S63Q5kXgrw85F85QS1cntR
A clone cannot follow `add_project`: the row has to exist while the checkout
is still running, but seeding a layout or firing `on_project_open` would cd
into a directory that is not there.

`register_pending_project` is the clone counterpart of
`register_worktree_project_deferred_hooks` — the row lands with no layout
and no hooks. `finish_pending_project` seeds the layout and fires the hooks
once the directory is real; `remove_pending_project` rolls the row back when
the checkout fails, guarded like `remove_stale_worktree` so it never touches
a row that belongs to an operation still in flight.

`resolve_clone_target` joins the parent and the directory on the host that
will do the cloning, not on the caller — a remote daemon need not share the
client's path conventions. It also keeps the directory a NAME: separators
and `..` are rejected, so the checkout cannot land outside the parent the
user picked.

The full `ProjectData` shape for new projects now lives in one
`new_project_row` helper, and `add_project` reuses the existing
`fire_project_open_hooks` instead of its own copy of that tail.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01S63Q5kXgrw85F85QS1cntR
`CloneProject { url, parent_dir, directory, name }` carries the parent and
the directory name separately so the receiving host joins them with its own
separator; a client cannot predict a remote daemon's path shape.

The `execute_action` arm clones and then adds the project, blocking end to
end. The daemon intercepts this action ahead of `execute_action` and does
the same work off the reactor (next commit); this path serves callers that
drive `execute_action` directly, and it is what the new tests exercise
against a real repository.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01S63Q5kXgrw85F85QS1cntR
Same split as `CreateWorktree`, for the same reason and more so: a clone is
network-bound and unbounded, so running it through the synchronous
`execute_action` path would hold the workspace lock for the whole fetch and
stall every other daemon action.

The URL and the target resolve first with no lock held, so bad input fails
the request outright instead of creating a row that vanishes a moment later.
Then an optimistic row lands (no layout, so the client renders the creating
placeholder), the clone runs on a blocking thread, and the fast mutations —
seed layout, fire `on_project_open`, spawn PTYs — happen under a brief lock.
Failure rolls the row back and toasts.

A stale completion (the workspace was replaced by a session load) leaves the
clone on disk, unlike the worktree path: it is a plain directory of the
user's code, and deleting it unprompted is worse than leaving it.

The reply carries `pending: true` — same contract as `CreateWorktree`, so
callers know `path` does not exist yet.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01S63Q5kXgrw85F85QS1cntR
A `Source: Folder | Git` toggle. In Git mode the dialog asks for the URL,
the parent to clone into, and the folder name to create inside it — the
folder name is derived from the URL but stays editable, because the name a
repository ships with is not always the one you want on disk.

The fills chain: URL fills the folder name, the folder name fills the
project name. A field holding anything other than what the dialog put there
belongs to the user and stops being overwritten. Both are driven by
`InputChangedEvent`, not by notify — the cursor blink notifies twice a
second, and re-running a fill on those would keep resetting the caret.

The path completion list now anchors to the input's painted bounds instead
of a hardcoded offset; the offset was already approximate, and the extra
rows in Git mode would have put the list in the wrong place.

The creating placeholder tells a clone from a worktree, so a cloning project
no longer claims to be setting up a worktree.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01S63Q5kXgrw85F85QS1cntR
`okena project clone <url> [--into <parent>] [--dir <name>] [--name <n>]`.
`--into` defaults to the CWD and resolves against it, since the daemon does
not share this process's working directory; `--dir` defaults to the name
git would pick.

Placement (`--hidden`, `--folder`) is shared with `project add` rather than
copied — the two commands differ only in how the directory comes to exist.

Like `worktree add`, the clone is optimistic: the id and path print
immediately and a note goes to stderr, so a script does not `cd` into a path
that is still being fetched.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01S63Q5kXgrw85F85QS1cntR
Cancel an in-flight git command when daemon shutdown drops its task. Reconcile interrupted clone rows during startup, and reject Windows drive prefixes as clone directory names.
Formatting only, no behaviour change. `cargo fmt --all --check` reported
these against the branch before any of the following commits touched it;
separating them keeps the real diffs readable.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Xb6YVTnTpkMEZ8smJ1U8Sc
A `git clone` of a private repo hung indefinitely with nothing in the log.
The child was stopped (state T, `do_signal_stop`), not running: git asks for
credentials on `/dev/tty` rather than stdin, and reading a terminal from a
background process group raises SIGTTIN. The bus then blocked in `wait()`
forever.

`setsid` closes that off. A new session has no controlling terminal, so
`/dev/tty` cannot be opened to prompt on in the first place. It also starts a
new process group whose id is the child's pid, which is exactly the identity
`ProcessTree::terminate` kills, so this replaces `process_group(0)` rather
than fighting it. `setpgid` stays as a fallback so that invariant holds even
if `setsid` ever fails.

Applies to every bus command, which is the right scope: they all run with
stdin on /dev/null and their output piped, so none of them can service a
prompt anyway — they can only hang on one.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Xb6YVTnTpkMEZ8smJ1U8Sc
Cloning a private repo with no credential helper configured left git waiting
on a username prompt it could never show. The previous commit stops that
from hanging; this makes it fail with something worth reading instead.

`network_command()` builds the git command for anything that reaches a
remote, and every clone/fetch/push site now goes through it:

- `GIT_TERMINAL_PROMPT=0` — git dies with "terminal prompts disabled".
- `GIT_ASKPASS` / `SSH_ASKPASS` set to empty. Empty rather than unset is
  deliberate: git reads an empty value as "set but unusable" and skips
  askpass entirely, where unsetting would let it fall through to
  `core.askpass`.
- `GCM_INTERACTIVE=never` for Git Credential Manager.
- `GIT_SSH_COMMAND=ssh -o BatchMode=yes`, but only when the user has not
  set one of their own. BatchMode still authenticates through an agent; it
  turns passphrase and host-key prompts into failures rather than hangs.

Fetch and push had the same exposure and are covered too, before anyone hit
it there.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Xb6YVTnTpkMEZ8smJ1U8Sc
The failure toast read:

    Clone failed: git exited with status 128: Cloning into '/home/x/repo'...
    fatal: could not read Username for 'https://github.com': terminal prompts disabled

Two lines of noise ahead of the one that says what went wrong. Git writes
progress to stderr alongside errors, so the raw message opens with chatter
and buries the cause.

`GitError::user_detail()` picks out git's own error line — the last `fatal:`
/ `error:` / `remote: error:` — and drops the prefix, falling back to the
exit status when nothing matches. The toast now carries "Clone failed" with
that line as its detail, and the log keeps the untouched message.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Xb6YVTnTpkMEZ8smJ1U8Sc
Startup recovery decided a mid-create project was finished purely from the
target directory existing. That is not a safe test for a clone: `git clone`
is not atomic, and one killed mid-fetch (daemon shutdown kills the process
tree) leaves the directory behind holding a `.git` with an unborn HEAD. The
row was then promoted to a normal project — layout seeded, terminal spawned
— for a repo with no files in it.

`is_complete_checkout` draws the line at HEAD resolving to a commit, which
git writes only once the fetch has landed the branch it is about to check
out. Both reconciliation sites now share one predicate; they have to agree,
because a half-clone that is neither removed nor finished stays marked
creating forever — the stuck-on-"cloning" state this recovery exists to
prevent.

Worktrees keep the existence test: `git worktree add` has no equivalent
half-done state.

Known gap: a clone killed during checkout, after HEAD is written, still
passes. That window is far smaller than the fetch, and closing it properly
means cloning to a temp directory and renaming on success.

The directory itself is left on disk. Removing a directory of the user's
code unprompted is worse than leaving it, matching how the stale-epoch path
already reasons; the cost is that a retry reports the target as non-empty.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Xb6YVTnTpkMEZ8smJ1U8Sc
A clone of a large repo sat on "Cloning repository…" for minutes with no way
to tell it from a hang. Git knows how far it has got; nothing carried that
to the UI.

The bus was the obstacle: it read each pipe with `read_to_end` and handed
the output back only once the process exited, so progress arrived exactly
when it stopped being useful. `CommandSpec::on_stderr_line` adds an opt-in
sink fed as the bytes arrive. Lines break on `\r` as well as `\n` — progress
tools rewrite one line with a carriage return, and splitting on `\n` alone
would hold every update back to the end, which is the buffering this
removes. Captured output is unchanged, and a line buffer that never breaks
is capped rather than grown without bound.

`git clone` then runs with `--progress`, which is required rather than
cosmetic: git reports progress only when stderr is a terminal, and the bus
always pipes it. `parse_clone_progress` reads the phase and percentage and
ignores every other line git writes.

`ProjectData.creating_progress` carries it to the UI and over the wire. Not
persisted, for the reason `is_closing` is not: it describes a live process,
and reloading a stale percentage after a restart would claim progress that
nothing is making. `set_creating_progress` reports whether anything changed
so an unchanged value costs no broadcast, and ignores a project that is no
longer being created — progress comes off a reader thread and can land late,
which must not resurrect the placeholder.

Publishing is limited to one update per 250ms because each takes the
workspace lock and pushes a snapshot to every client; 100% always goes
through so a phase never appears to stall short of finishing.

The project column shows the line under the placeholder, and the sidebar row
replaces its generic "Creating…" with it.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Xb6YVTnTpkMEZ8smJ1U8Sc
@matej21
matej21 force-pushed the feat/clone-project-from-git branch from 61fc6ed to 2a39c81 Compare August 18, 2026 13:11
`clippy::items_after_test_module` is denied in CI and the new `mod tests`
landed above `GitResult`, breaking the build. Test module goes last.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Xb6YVTnTpkMEZ8smJ1U8Sc
@matej21
matej21 merged commit 0fa4dbd into main Aug 18, 2026
10 checks passed
@matej21
matej21 deleted the feat/clone-project-from-git branch August 18, 2026 13:35
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant