feat: add a project by cloning a git repository - #183
Merged
Conversation
matej21
force-pushed
the
feat/clone-project-from-git
branch
from
August 18, 2026 09:51
6fb3322 to
61fc6ed
Compare
`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
force-pushed
the
feat/clone-project-from-git
branch
from
August 18, 2026 13:11
61fc6ed to
2a39c81
Compare
`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
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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 | Gittoggle. 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 asAddProject.CLI —
okena project clone <url> [--into <parent>] [--dir <name>] [--name <n>] [--hidden] [--folder <f>].--intodefaults to the CWD,--dirto the name git would pick.How it runs
The clone follows the
CreateWorktreepattern: the daemon registers an optimistic project row (no layout, so the column renders a "Cloning repository…" placeholder), runsgit cloneon a blocking thread with no workspace lock held, then seeds the layout, fireson_project_openand spawns terminals under a brief lock. A failure rolls the row back and toasts. Running it through the synchronousexecute_actionpath instead would hold the lock for the whole fetch and stall every other daemon action.git cloneruns onLane::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:project clone <repo>--dir renamed --name "My Clone"--dir ../escape--upload-pack=evilcargo test --workspaceandcargo clippy --workspace --all-targetsare 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