@@ -3,16 +3,27 @@ import Foundation
33import GraphcodeKit
44import 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.
109struct 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
1829enum 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+
45214extension DependencyValues {
46215 var gitClient : GitClient {
47216 get { self [ GitClient . self] }
0 commit comments