Skip to content

Commit 719dcb8

Browse files
scgopiclaude
andcommitted
Add a project straight from a remote repository URL
Sidebar ⊕ → Clone Repository…: paste a git URL, pick where it lands (remembered across clones), optionally a folder name, branch, or depth. GitClient streams `git clone --progress` so the sheet shows git's own progress line; a failure keeps the sheet open with git's explanation, https credentials are redacted from everything shown, a partial clone the run created is removed, and git is never allowed to prompt — no tty means fail fast, not hang. Success routes through the exact .openProject path a picked folder takes, so nothing downstream knows the project began as a URL. Drawn on supacode's clone form (shape, not code). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
1 parent a570c24 commit 719dcb8

5 files changed

Lines changed: 641 additions & 5 deletions

File tree

graphcode/Sources/Clients/GitClient.swift

Lines changed: 173 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -3,16 +3,27 @@ import Foundation
33
import GraphcodeKit
44
import os
55

6-
/// Graphcode's own minimal git client — create/list/remove a worktree, nothing more.
7-
/// A `LoopNode`'s `worktreeBinding` is the only thing that needs this; it is not a
8-
/// general git porcelain layer. See docs/03-architecture.md and
9-
/// docs/07-roadmap.md#phase-1--single-loop-manual-check.
6+
/// Graphcode's own minimal git client — worktrees, and cloning a remote repository so a
7+
/// project can be added straight from a URL. It is not a general git porcelain layer.
8+
/// See docs/03-architecture.md and docs/07-roadmap.md#phase-1--single-loop-manual-check.
109
struct GitClient: Sendable {
1110
var createWorktree:
1211
@Sendable (_ repositoryPath: String, _ worktreePath: String, _ branch: String) async throws ->
1312
WorktreeRef
1413
var listWorktrees: @Sendable (_ repositoryPath: String) async throws -> [WorktreeRef]
1514
var removeWorktree: @Sendable (_ worktree: WorktreeRef) async throws -> Void
15+
/// Streams a `git clone --progress` into `destination`: progress lines while it runs,
16+
/// `.finished` on success, a thrown `GitClientError` on failure. The drawn-on shape is
17+
/// supacode's `cloneStream` (structure, not code): streaming is what lets the form show
18+
/// a live percentage instead of a spinner over a multi-minute network operation.
19+
var clone:
20+
@Sendable (_ url: String, _ destination: URL, _ branch: String?, _ depth: Int?) ->
21+
AsyncThrowingStream<GitCloneEvent, any Error>
22+
}
23+
24+
enum GitCloneEvent: Equatable, Sendable {
25+
case progress(String)
26+
case finished
1627
}
1728

1829
enum GitClientError: Error, Equatable {
@@ -38,10 +49,168 @@ extension GitClient: DependencyKey {
3849
removeWorktree: { worktree in
3950
_ = try await run(
4051
"git", ["-C", worktree.repositoryPath, "worktree", "remove", worktree.worktreePath])
52+
},
53+
clone: { url, destination, branch, depth in
54+
runClone(url: url, destination: destination, branch: branch, depth: depth)
4155
}
4256
)
4357
}
4458

59+
extension GitClient {
60+
/// Git's own "humanish" directory name for a clone URL — the last path component with
61+
/// `.git` stripped. Parsed from the raw string because scp-style remotes
62+
/// (`git@host:org/repo.git`) are not URLs Foundation can take apart. Empty when no
63+
/// leaf derives, which the form treats as "nothing to prefill".
64+
static func humanishName(forCloneURL url: String) -> String {
65+
var trimmed = url.trimmingCharacters(in: .whitespacesAndNewlines)
66+
if let queryIndex = trimmed.firstIndex(where: { $0 == "?" || $0 == "#" }) {
67+
trimmed = String(trimmed[..<queryIndex])
68+
}
69+
while trimmed.hasSuffix("/") { trimmed.removeLast() }
70+
if let separatorIndex = trimmed.lastIndex(where: { $0 == "/" || $0 == ":" }) {
71+
trimmed = String(trimmed[trimmed.index(after: separatorIndex)...])
72+
}
73+
if trimmed.hasSuffix(".git") { trimmed.removeLast(4) }
74+
return trimmed
75+
}
76+
77+
/// The secret userinfo of an http(s) clone URL (`token` or `user:password`), so it can
78+
/// be blanked out of every progress line and error shown to a human. An ssh user
79+
/// (`git@host`) is a login name, not a secret, and URLComponents doesn't parse
80+
/// scp-style remotes anyway — both fall out as nil.
81+
static func cloneCredentials(of url: String) -> String? {
82+
guard let components = URLComponents(string: url),
83+
let user = components.percentEncodedUser, !user.isEmpty
84+
else { return nil }
85+
guard let password = components.percentEncodedPassword, !password.isEmpty else {
86+
return user
87+
}
88+
return "\(user):\(password)"
89+
}
90+
}
91+
92+
/// The live `clone` implementation. Progress arrives on stderr in `\r`-separated
93+
/// updates (git redraws one line in place), so the reader splits on both `\r` and `\n`
94+
/// and yields each completed piece.
95+
private func runClone(
96+
url: String, destination: URL, branch: String?, depth: Int?
97+
) -> AsyncThrowingStream<GitCloneEvent, any Error> {
98+
AsyncThrowingStream { continuation in
99+
let destinationPath = destination.standardizedFileURL.path
100+
// Only a directory the clone itself created is ours to remove on failure — a
101+
// pre-existing one (git will refuse it anyway if non-empty) is the user's.
102+
let existedBefore = FileManager.default.fileExists(atPath: destinationPath)
103+
let credentials = GitClient.cloneCredentials(of: url)
104+
105+
let process = cloneProcess(
106+
url: url, destinationPath: destinationPath, branch: branch, depth: depth)
107+
let stdout = Pipe()
108+
let stderr = Pipe()
109+
process.standardOutput = stdout
110+
process.standardError = stderr
111+
112+
// Collected for the error message: git's explanation of a failure is its last few
113+
// stderr lines, and `commandFailed` should carry them rather than a bare status.
114+
let recentLines = OSAllocatedUnfairLock(initialState: [String]())
115+
let onLine: @Sendable (String) -> Void = { line in
116+
let shown =
117+
credentials.map { line.replacingOccurrences(of: $0, with: "•••") } ?? line
118+
recentLines.withLock { recent in
119+
recent.append(shown)
120+
if recent.count > 5 { recent.removeFirst() }
121+
}
122+
continuation.yield(.progress(shown))
123+
}
124+
attachLineReader(to: stdout, onLine: onLine)
125+
attachLineReader(to: stderr, onLine: onLine)
126+
127+
process.terminationHandler = { process in
128+
stdout.fileHandleForReading.readabilityHandler = nil
129+
stderr.fileHandleForReading.readabilityHandler = nil
130+
if process.terminationStatus == 0 {
131+
continuation.yield(.finished)
132+
continuation.finish()
133+
return
134+
}
135+
// A failed clone that created the directory leaves a partial repo the next
136+
// attempt would refuse; remove what this run made and nothing else.
137+
if !existedBefore {
138+
try? FileManager.default.removeItem(atPath: destinationPath)
139+
}
140+
continuation.finish(
141+
throwing: GitClientError.commandFailed(
142+
command: "git clone",
143+
status: process.terminationStatus,
144+
output: recentLines.withLock { $0.joined(separator: "\n") }))
145+
}
146+
147+
continuation.onTermination = { reason in
148+
// The sheet was dismissed mid-clone: end the process; its termination handler
149+
// then does the partial-clone cleanup.
150+
if case .cancelled = reason, process.isRunning { process.terminate() }
151+
}
152+
153+
do {
154+
try process.run()
155+
} catch {
156+
continuation.finish(throwing: error)
157+
}
158+
}
159+
}
160+
161+
/// The `git clone` invocation, environment included.
162+
private func cloneProcess(
163+
url: String, destinationPath: String, branch: String?, depth: Int?
164+
) -> Process {
165+
var arguments = ["git", "clone", "--progress"]
166+
if let branch, !branch.isEmpty { arguments += ["--branch", branch] }
167+
if let depth { arguments += ["--depth", String(depth)] }
168+
arguments += [url, destinationPath]
169+
170+
let process = Process()
171+
process.executableURL = URL(fileURLWithPath: "/usr/bin/env")
172+
process.arguments = arguments
173+
var environment = ProcessInfo.processInfo.environment
174+
// No tty means no one to answer a prompt: fail fast on credentials and host keys
175+
// instead of hanging the sheet on a question it can never show.
176+
environment["GIT_TERMINAL_PROMPT"] = "0"
177+
environment["GIT_SSH_COMMAND"] =
178+
"ssh -o BatchMode=yes -o ConnectTimeout=10 -o StrictHostKeyChecking=accept-new"
179+
// Untranslated output, so the progress parsing and the errors people paste into
180+
// issues are the same everywhere.
181+
environment["LC_ALL"] = "C"
182+
process.environment = environment
183+
return process
184+
}
185+
186+
/// Feeds `onLine` every completed line out of `pipe`, treating `\r` (git's redraw-in-
187+
/// place separator) the same as `\n`, and dropping blanks.
188+
private func attachLineReader(to pipe: Pipe, onLine: @escaping @Sendable (String) -> Void) {
189+
let remainder = OSAllocatedUnfairLock(initialState: "")
190+
pipe.fileHandleForReading.readabilityHandler = { handle in
191+
let chunk = handle.availableData
192+
guard !chunk.isEmpty else {
193+
handle.readabilityHandler = nil
194+
return
195+
}
196+
guard let text = String(data: chunk, encoding: .utf8) else { return }
197+
let pieces = remainder.withLock { remainder in
198+
var buffered = remainder + text
199+
var lines: [String] = []
200+
while let breakIndex = buffered.firstIndex(where: { $0 == "\r" || $0 == "\n" }) {
201+
lines.append(String(buffered[..<breakIndex]))
202+
buffered = String(buffered[buffered.index(after: breakIndex)...])
203+
}
204+
remainder = buffered
205+
return lines
206+
}
207+
for piece in pieces {
208+
let line = piece.trimmingCharacters(in: .whitespaces)
209+
if !line.isEmpty { onLine(line) }
210+
}
211+
}
212+
}
213+
45214
extension DependencyValues {
46215
var gitClient: GitClient {
47216
get { self[GitClient.self] }

graphcode/Sources/Features/App/AppSidebarView.swift

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -85,6 +85,16 @@ struct AppSidebarView: View {
8585
) { result in
8686
store.send(.welcome(.folderPickerResult(result)))
8787
}
88+
// The clone sheet. Dismissing it mid-clone cancels the clone — that's
89+
// `.cloneCancelled`'s job, not a side effect of the binding.
90+
.sheet(
91+
isPresented: Binding(
92+
get: { store.welcome.cloneDraft != nil },
93+
set: { if !$0 { store.send(.welcome(.cloneCancelled)) } }
94+
)
95+
) {
96+
CloneRepositoryFormView(store: store.scope(state: \.welcome, action: \.welcome))
97+
}
8898
.confirmationDialog(
8999
"Delete this project's loops?",
90100
isPresented: Binding(
@@ -126,6 +136,13 @@ struct AppSidebarView: View {
126136
} label: {
127137
Label("Open Folder…", systemImage: "folder")
128138
}
139+
// A project can also start as a URL — the clone lands locally and opens through
140+
// the same path a picked folder takes. See `WelcomeFeature.CloneDraft`.
141+
Button {
142+
store.send(.welcome(.cloneRepositoryButtonTapped))
143+
} label: {
144+
Label("Clone Repository…", systemImage: "square.and.arrow.down.on.square")
145+
}
129146
if !store.welcome.recentProjects.isEmpty {
130147
Divider()
131148
ForEach(store.welcome.recentProjects) { project in
Lines changed: 108 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,108 @@
1+
import ComposableArchitecture
2+
import GraphcodeKit
3+
import SwiftUI
4+
5+
/// The clone-a-remote-repository sheet — paste a URL, pick where it lands, and the
6+
/// finished clone opens as an ordinary project. Progress streams into the sheet and a
7+
/// failure keeps it open with git's own explanation, so a typo'd URL costs a retry
8+
/// rather than a reopened form.
9+
struct CloneRepositoryFormView: View {
10+
@Bindable var store: StoreOf<WelcomeFeature>
11+
12+
var body: some View {
13+
VStack(spacing: 12) {
14+
Text("Clone Repository").font(.headline)
15+
16+
Form {
17+
TextField(
18+
"Repository", text: repositoryURL,
19+
prompt: Text("https://github.com/you/repo.git")
20+
)
21+
.autocorrectionDisabled()
22+
.font(.system(.body, design: .monospaced))
23+
24+
HStack(spacing: 6) {
25+
TextField("Location", text: locationPath, prompt: Text("where the clone lands"))
26+
.font(.system(.body, design: .monospaced))
27+
Button("Choose…") {
28+
store.send(.binding(.set(\.isCloneLocationPickerPresented, true)))
29+
}
30+
}
31+
32+
TextField(
33+
"Folder", text: folderName,
34+
prompt: Text(derivedFolderPlaceholder))
35+
36+
TextField("Branch", text: branch, prompt: Text("default branch — optional"))
37+
TextField("Depth", text: depth, prompt: Text("full history — optional"))
38+
.font(.system(.body, design: .monospaced))
39+
}
40+
.formStyle(.columns)
41+
.fixedSize(horizontal: false, vertical: true)
42+
43+
// One line of status: the live progress while cloning, the failure after one,
44+
// nothing otherwise. Progress lines are git's own (`Receiving objects: 42% …`).
45+
if let progress = store.cloneDraft?.progressLine, store.cloneDraft?.isCloning == true {
46+
Text(progress)
47+
.font(.caption.monospacedDigit())
48+
.foregroundStyle(.secondary)
49+
.lineLimit(1)
50+
.frame(maxWidth: .infinity, alignment: .leading)
51+
}
52+
if let failure = store.cloneDraft?.failureMessage {
53+
Text(failure)
54+
.font(.caption)
55+
.foregroundStyle(.red)
56+
.textSelection(.enabled)
57+
.frame(maxWidth: .infinity, alignment: .leading)
58+
}
59+
60+
HStack {
61+
Button("Cancel") { store.send(.cloneCancelled) }
62+
Spacer()
63+
if store.cloneDraft?.isCloning == true {
64+
ProgressView().controlSize(.small).padding(.trailing, 4)
65+
}
66+
Button("Clone") { store.send(.cloneSubmitted) }
67+
.keyboardShortcut(.defaultAction)
68+
.disabled(store.cloneDraft?.canSubmit != true)
69+
}
70+
}
71+
.padding(24)
72+
.frame(width: 460)
73+
.fileImporter(
74+
isPresented: $store.isCloneLocationPickerPresented,
75+
allowedContentTypes: [.folder]
76+
) { result in
77+
store.send(.cloneLocationPicked(result.mapError { $0 }))
78+
}
79+
}
80+
81+
private var derivedFolderPlaceholder: String {
82+
let derived = store.cloneDraft?.derivedFolderName ?? ""
83+
return derived.isEmpty ? "derived from the URL" : derived
84+
}
85+
86+
// Bindings into the optional draft. Writing into a dismissed sheet's draft is a
87+
// no-op rather than a crash, matching how the reducer treats its own optionals.
88+
private var repositoryURL: Binding<String> {
89+
draftBinding(\.repositoryURL)
90+
}
91+
private var locationPath: Binding<String> { draftBinding(\.locationPath) }
92+
private var folderName: Binding<String> { draftBinding(\.folderName) }
93+
private var branch: Binding<String> { draftBinding(\.branch) }
94+
private var depth: Binding<String> { draftBinding(\.depth) }
95+
96+
private func draftBinding(
97+
_ keyPath: WritableKeyPath<WelcomeFeature.CloneDraft, String>
98+
) -> Binding<String> {
99+
Binding(
100+
get: { store.cloneDraft?[keyPath: keyPath] ?? "" },
101+
set: { newValue in
102+
guard var draft = store.cloneDraft else { return }
103+
draft[keyPath: keyPath] = newValue
104+
store.send(.binding(.set(\.cloneDraft, draft)))
105+
}
106+
)
107+
}
108+
}

0 commit comments

Comments
 (0)