#lang racket/base (require ffi/unsafe racket/async-channel racket/list racket/match racket/path racket/string "credentials.rkt" libgit2) (provide git dgit git-repository? git-root git-init git-clone (struct-out git-status-entry) git-status git-status-lines git-clean? git-diff git-add git-config git-config-get git-config-set git-commit git-head git-current-branch git-branches git-branch git-branch-delete git-checkout git-checkout-new git-tags git-tag git-tag-delete (struct-out git-log-entry) git-log git-log-lines git-remotes git-remote-add git-remote-url git-fetch git-pull git-push git-push-tag git-credentials-init! git-credentials-unlock! git-credentials-lock! git-credentials-unlocked? git-credentials-unlock-expires git-credentials-set! git-credentials-ref git-credentials-configured? git-credentials-remove!) (struct git-status-entry (path code flags) #:transparent) (struct git-log-entry (id summary time) #:transparent) (define zero-oid-string (make-string GIT_OID_HEXSZ #\0)) (define branch-prefix "refs/heads/") (define tag-prefix "refs/tags/") (define GIT-CREDTYPE-USERPASS-PLAINTEXT #x0001) (define GIT-CREDTYPE-USERNAME #x0020) (define GIT-PASSTHROUGH -30) (define GIT-CHECKOUT-OPTIONS-VERSION 1) (define (git-credential-callback out url username-from-url allowed-types _payload) ;; Never let a Racket exception escape through a C callback. In particular, ;; a locked credential store used to throw here, which can destabilize the ;; enclosing Racket/DrRacket process. (with-handlers ([exn:fail? (lambda (_) GIT-PASSTHROUGH)]) (define saved (git-credentials-ref url)) (cond [(not saved) GIT-PASSTHROUGH] [else (define username (if (and username-from-url (not (string=? username-from-url ""))) username-from-url (car saved))) (define token (cdr saved)) (cond [(not (zero? (bitwise-and allowed-types GIT-CREDTYPE-USERPASS-PLAINTEXT))) (ptr-set! out _git_credential (git_credential_userpass_plaintext_new username token)) 0] [(not (zero? (bitwise-and allowed-types GIT-CREDTYPE-USERNAME))) ;; This credential is only used as an intermediate username response. ;; libgit2 owns the credential after the callback returns. (ptr-set! out _git_credential (git_credential_username_new username)) 0] [else GIT-PASSTHROUGH])]))) (define (set-credential-callback! callbacks) ;; A blocking libgit2 callout may dispatch this callback to another Racket ;; thread. Preserve the caller's credential-store parameterization there. (define credentials-store (current-git-credentials-store)) (define unlock-store (current-git-credentials-unlock-store)) (set-git_remote_callbacks-credentials! callbacks (lambda args (parameterize ([current-git-credentials-store credentials-store] [current-git-credentials-unlock-store unlock-store]) (apply git-credential-callback args)))) callbacks) (define (progress-message quiet format-string . args) (unless quiet (apply fprintf (current-output-port) format-string args) (newline) (flush-output))) (define (progress-phase-name phase) (case phase [(1) "packing"] [(2) "compressing"] [(3) "sending"] [else #f])) (define (make-progress-reporter label quiet #:bytes? [bytes? #t] #:phase? [phase? #f]) (define last-phase -1) (define last-percent -10) (lambda (phase current total bytes) (unless (or quiet (zero? total)) (when (not (= phase last-phase)) (set! last-phase phase) (set! last-percent -10)) (define percent (min 100 (quotient (* current 100) total))) (when (and (positive? current) (or (>= (- percent last-percent) 10) (= current total))) (set! last-percent percent) (define phase-name (and phase? (progress-phase-name phase))) (cond [(and phase-name bytes?) (progress-message #f "[git] ~a: ~a ~a% (~a/~a objects, ~a KiB)" label phase-name percent current total (quotient (+ bytes 1023) 1024))] [phase-name (progress-message #f "[git] ~a: ~a ~a% (~a/~a objects)" label phase-name percent current total)] [bytes? (progress-message #f "[git] ~a: ~a% (~a/~a objects, ~a KiB)" label percent current total (quotient (+ bytes 1023) 1024))] [else (progress-message #f "[git] ~a: ~a% (~a/~a objects)" label percent current total)]))))) ;; Progress callbacks must never perform I/O. For blocking libgit2 callouts the ;; binding dispatches them to a safe Racket callback thread; they only update ;; this pre-allocated state. A separate ordinary Racket thread performs output. (define (make-progress-state) ;; phase, current, total, bytes (vector 0 0 0 0)) (define (progress-state-set! state phase current total bytes) (vector-set! state 0 phase) (vector-set! state 1 current) (vector-set! state 2 total) (vector-set! state 3 bytes)) (define (progress-state-ref state) (values (vector-ref state 0) (vector-ref state 1) (vector-ref state 2) (vector-ref state 3))) (define (report-progress-snapshot report state) (define-values (phase current total bytes) (progress-state-ref state)) (report phase current total bytes)) (define (call-with-progress label quiet state thunk #:bytes? [bytes? #t] #:phase? [phase? #f]) ;; Run the blocking libgit2 operation in its own parallel Racket thread. ;; The caller remains a normal coroutine thread and is therefore free to ;; update DrRacket/console output. libgit2 callbacks are dispatched back to ;; a safe ordinary Racket thread by the libgit2 binding. (define result-channel (make-async-channel)) (define report (and (not quiet) (make-progress-reporter label #f #:bytes? bytes? #:phase? phase?))) (thread #:pool 'own (lambda () (with-handlers ([exn? (lambda (e) (async-channel-put result-channel (cons 'error e)))]) (call-with-values thunk (lambda results (async-channel-put result-channel (cons 'ok results))))))) (define (return-result result) (when report (report-progress-snapshot report state)) (case (car result) [(ok) (apply values (cdr result))] [(error) (raise (cdr result))])) (cond [quiet (return-result (sync result-channel))] [else (let loop () (define result (sync/timeout 0.1 result-channel)) (cond [result (return-result result)] [else (report-progress-snapshot report state) (loop)]))])) (define (transfer-progress-total-objects stats) (ptr-ref stats _uint 0)) (define (transfer-progress-received-objects stats) (ptr-ref stats _uint 2)) (define (transfer-progress-received-bytes stats) ;; git_transfer_progress has six unsigned-int fields followed by size_t. (ptr-ref (ptr-add stats (* 6 (ctype-sizeof _uint))) _size)) (define (set-fetch-progress-callback! callbacks state) (set-git_remote_callbacks-transfer_progress! callbacks (lambda (stats _payload) (progress-state-set! state 0 (transfer-progress-received-objects stats) (transfer-progress-total-objects stats) (transfer-progress-received-bytes stats)) 0)) callbacks) (define (set-push-progress-callback! callbacks state) (set-git_remote_callbacks-pack_progress! callbacks (lambda (stage current total _payload) ;; 0 = adding objects, 1 = deltafication in libgit2 1.4. (progress-state-set! state (if (zero? stage) 1 2) current total 0) 0)) (set-git_remote_callbacks-push_transfer_progress! callbacks (lambda (current total bytes _payload) (progress-state-set! state 3 current total bytes) 0)) callbacks) (define (make-fetch-options) (define state (make-progress-state)) (define options (cast (malloc _git_fetch_opts 'atomic-interior) _pointer _git_fetch_opts-pointer)) (git_fetch_options_init options GIT_FETCH_OPTS_VERSION) (define callbacks (git_fetch_opts-callbacks options)) (set-credential-callback! callbacks) (set-fetch-progress-callback! callbacks state) (values options state)) (define (make-push-options) (define state (make-progress-state)) (define options (cast (malloc _git_push_opts 'atomic-interior) _pointer _git_push_opts-pointer)) (git_push_options_init options GIT_PUSH_OPTS_VERSION) (define callbacks (git_push_opts-callbacks options)) (set-credential-callback! callbacks) (set-push-progress-callback! callbacks state) (values options state)) (define (make-clone-options) (define state (make-progress-state)) (define options (cast (malloc _git_clone_opts 'atomic-interior) _pointer _git_clone_opts-pointer)) (git_clone_options_init options GIT_CLONE_OPTS_VERSION) (define callbacks (git_fetch_opts-callbacks (git_clone_opts-fetch_opts options))) (set-credential-callback! callbacks) (set-fetch-progress-callback! callbacks state) (values options state)) (define (blank-oid) (git_oid_fromstr zero-oid-string)) (define (repository-path [start (current-directory)]) (or (git_repository_discover start) (error 'git "not inside a Git repository: ~a" start))) (define (open-repository [start (current-directory)]) (git_repository_open (repository-path start))) (define (git-repository? [path (current-directory)]) (and (git_repository_discover path) #t)) (define (git-root [path (current-directory)]) (define repo (open-repository path)) (define root (if (git_repository_is_bare repo) (git_repository_path repo) (git_repository_workdir repo))) (simplify-path (string->path root))) (define (git-init [path (current-directory)] #:bare? [bare? #f]) (git_repository_init path #:bare? bare?) (git-root path)) (define (default-clone-directory url) (define cleaned (regexp-replace #rx"/+$" url "")) (define parts (filter (lambda (s) (not (string=? s ""))) (regexp-split #rx"[/\\\\:]" cleaned))) (unless (pair? parts) (error 'git-clone "cannot derive a directory name from ~a" url)) (regexp-replace #rx"[.]git$" (last parts) "")) (define (git-clone url [path (default-clone-directory url)] #:quiet [quiet #f]) (define label (format "clone ~a" url)) (progress-message quiet "[git] ~a" label) (define-values (options state) (make-clone-options)) (call-with-progress label quiet state (lambda () (git_clone url (path->string (if (path? path) path (string->path path))) options))) (progress-message quiet "[git] ~a: done" label) (git-root path)) (define (normalize-status-flags flags) (cond [(list? flags) flags] [(symbol? flags) (if (eq? flags 'GIT_STATUS_CURRENT) null (list flags))] [else null])) (define (has-status? flags flag) (and (memq flag flags) #t)) (define (index-status-char flags) (cond [(has-status? flags 'GIT_STATUS_INDEX_NEW) #\A] [(has-status? flags 'GIT_STATUS_INDEX_MODIFIED) #\M] [(has-status? flags 'GIT_STATUS_INDEX_DELETED) #\D] [(has-status? flags 'GIT_STATUS_INDEX_RENAMED) #\R] [(has-status? flags 'GIT_STATUS_INDEX_TYPECHANGE) #\T] [else #\space])) (define (worktree-status-char flags) (cond [(has-status? flags 'GIT_STATUS_WT_MODIFIED) #\M] [(has-status? flags 'GIT_STATUS_WT_DELETED) #\D] [(has-status? flags 'GIT_STATUS_WT_RENAMED) #\R] [(has-status? flags 'GIT_STATUS_WT_TYPECHANGE) #\T] [(has-status? flags 'GIT_STATUS_WT_UNREADABLE) #\?] [else #\space])) (define (status-code flags) (cond [(has-status? flags 'GIT_STATUS_CONFLICTED) "UU"] [(has-status? flags 'GIT_STATUS_IGNORED) "!!"] [(has-status? flags 'GIT_STATUS_WT_NEW) "??"] [else (string (index-status-char flags) (worktree-status-char flags))])) (define (git-status) (define repo (open-repository)) (define result null) (git_status_foreach repo (lambda (path raw-flags _payload) (define flags (normalize-status-flags raw-flags)) ;; Match normal `git status`: ignored files are not shown unless ;; explicitly requested. git_status_foreach uses libgit2's defaults, ;; which may include them. (unless (has-status? flags 'GIT_STATUS_IGNORED) (set! result (cons (git-status-entry path (status-code flags) flags) result))) 0) #"") (sort result (lambda (a b) (stringstring diff) (define bs (git_diff_to_buf diff 'GIT_DIFF_FORMAT_PATCH)) (if bs (bytes->string/utf-8 bs #\uFFFD) "")) (define (git-diff . args) (define repo (open-repository)) (define options (make-diff-options)) (define index (git_repository_index repo)) (define diff (match args ['() ;; Same basic comparison as `git diff`: index versus worktree. (git_diff_index_to_workdir repo index options)] [(list '--cached) ;; Same basic comparison as `git diff --cached`: HEAD tree versus index. (define parent (head-commit repo)) (unless parent (error 'git-diff "--cached requires an existing HEAD commit")) (define tree (git_commit_tree parent)) (git_diff_tree_to_index repo tree index options)] [_ (error 'git-diff "invalid arguments: ~e" args)])) (diff->string diff)) (define (git-path-string path) (regexp-replace* #rx"\\\\" (path->string path) "/")) (define (relative-pathspec workdir path) (define p0 (if (path? path) path (string->path path))) ;; A relative path supplied by the caller is relative to the caller's ;; current directory, not automatically to the repository root. (define p (path->complete-path p0 (current-directory))) (define rel (find-relative-path workdir p)) (define s (git-path-string rel)) (when (or (string=? s "..") (string-prefix? s "../")) (error 'git-add "path is outside the repository: ~a" path)) s) (define (index-accept _path _matched-pathspec _payload) 0) (define (git-add . paths) (define repo (open-repository)) (define workdir (string->path (git_repository_workdir repo))) (define index (git_repository_index repo)) (define pathspecs (make-git_strarray (for/list ([path (in-list paths)]) (relative-pathspec workdir path)))) ;; update-all stages changes and removals of already tracked files; ;; add-all adds new files and updates existing files while respecting ignores. (git_index_update_all index pathspecs index-accept #"") (git_index_add_all index pathspecs 'GIT_INDEX_ADD_DEFAULT index-accept #"") (git_index_write index) (void)) (define (git-config-get key) (define repo (open-repository)) (define config (git_repository_config repo)) ;; git_config_get_string is incorrectly declared as an allocating wrapper ;; in the current Racket libgit2 package. The entry API has the correct ;; ownership model and works on normal repository config objects. (define entry (git_config_get_entry config key)) (git_config_entry-value entry)) (define (git-config-set key value) (define repo (open-repository)) (define config (git_repository_config repo)) (git_config_set_string config key value) value) (define git-config (case-lambda [(key) (git-config-get key)] [(key value) (git-config-set key value)])) (define (head-commit repo) (cond [(git_repository_is_empty repo) #f] [(git_repository_head_unborn repo) #f] [else (define head (git_repository_head repo)) (git_commit_lookup repo (git_reference_target head))])) (define (git-head) (define repo (open-repository)) (define commit (head-commit repo)) (and commit (git_oid_fmt (git_commit_id commit)))) (define (git-commit message) (define repo (open-repository)) (define index (git_repository_index repo)) (when (git_index_has_conflicts index) (error 'git-commit "the index contains unresolved conflicts")) (define tree-id (blank-oid)) (git_index_write_tree tree-id index) (define tree (git_tree_lookup repo tree-id)) (define parent (head-commit repo)) (cond [(and (not parent) (zero? (git_index_entrycount index))) (error 'git-commit "nothing staged to commit")] [(and parent (git_oid_equal tree-id (git_commit_tree_id parent))) (error 'git-commit "nothing staged to commit")]) (define signature (git_signature_default repo)) (define commit-id (blank-oid)) (if parent (git_commit_create_v commit-id repo "HEAD" signature signature #f message tree 1 parent) (git_commit_create_v commit-id repo "HEAD" signature signature #f message tree 0)) (git_oid_fmt commit-id)) (define (git-current-branch) (define repo (open-repository)) (cond [(git_repository_head_detached repo) #f] [(git_repository_head_unborn repo) (define head (git_reference_lookup repo "HEAD")) (define target (git_reference_symbolic_target head)) (and target (string-prefix? target branch-prefix) (substring target (string-length branch-prefix)))] [else (git_reference_shorthand (git_repository_head repo))])) (define (git-branches) (define repo (open-repository)) (define branches null) (git_reference_foreach_name repo (lambda (name _payload) (when (string-prefix? name branch-prefix) (set! branches (cons (substring name (string-length branch-prefix)) branches))) 0) #"") (sort branches string)" remote-name)) url) (define (fetch-remote name quiet label) (check-remote-credentials 'git-fetch name) (progress-message quiet "[git] ~a" label) (define repo (open-repository)) (define remote (git_remote_lookup repo name)) (define-values (options state) (make-fetch-options)) (call-with-progress label quiet state (lambda () (git_remote_fetch/blocking remote #f options label))) (progress-message quiet "[git] ~a: done" label) (void)) (define (git-fetch [name "origin"] #:quiet [quiet #f]) (fetch-remote name quiet (format "fetch ~a" name))) (define (git-pull [remote-name "origin"] #:quiet [quiet #f]) (define branch (git-current-branch)) (unless branch (error 'git-pull "pull requires an attached local branch")) (define label (format "pull ~a/~a" remote-name branch)) (progress-message quiet "[git] ~a" label) (fetch-remote remote-name quiet (format "fetch ~a" remote-name)) (define repo (open-repository)) (define local-ref-name (string-append branch-prefix branch)) (define local-ref (git_reference_lookup repo local-ref-name)) (define local-id (git_reference_target local-ref)) (define remote-spec (format "refs/remotes/~a/~a^{commit}" remote-name branch)) (define remote-object (git_revparse_single repo remote-spec)) (define remote-id (git_object_id remote-object)) (cond [(git_oid_equal local-id remote-id) (progress-message quiet "[git] ~a: already up to date" label) #f] [(git_graph_descendant_of repo remote-id local-id) ;; Update the worktree safely before moving the branch reference. (git_checkout_tree repo remote-object (make-safe-checkout-options)) (git_reference_set_target local-ref remote-id (format "pull: fast-forward from ~a" remote-name)) (define oid (git_oid_fmt remote-id)) (progress-message quiet "[git] ~a: fast-forwarded" label) oid] [else (error 'git-pull "non-fast-forward pull is not supported; merge or rebase explicitly")])) (define (temporary-remote-name) (format "racket-git-push-~a-~a" (inexact->exact (floor (current-inexact-milliseconds))) (random 1000000000))) (define (remove-temporary-remote-refs! repo temp-name) (define prefix (format "refs/remotes/~a/" temp-name)) (for ([ref-name (in-list (git_reference_list repo))] #:when (string-prefix? ref-name prefix)) (git_reference_remove repo ref-name))) (define (push-refspec remote-name refspec quiet label) (define url (check-remote-credentials 'git-push remote-name)) (when (and (http-remote? url) (not (git-credentials-configured? url))) (error 'git-push "no HTTPS credentials are stored for ~a; use (git 'credentials 'set )" url)) (progress-message quiet "[git] ~a" label) (define repo (open-repository)) (define-values (options state) (make-push-options)) ;; The public Racket binding for git_remote_push cannot currently marshal a ;; non-null git_strarray correctly. Its null form is public and supported: ;; libgit2 then uses the remote's configured push refspecs. Use a temporary ;; remote so the user's real remote configuration is never modified. (define temp-name (temporary-remote-name)) (dynamic-wind (lambda () (git_remote_create repo temp-name url) (define config (git_repository_config repo)) (git_config_set_string config (format "remote.~a.push" temp-name) refspec)) (lambda () (define remote (git_remote_lookup repo temp-name)) (call-with-progress label quiet state (lambda () (git_remote_push/blocking remote #f options)) #:bytes? #f #:phase? #t)) (lambda () ;; git_remote_create installs a fetch refspec for the temporary remote. ;; A successful push can therefore leave a remote-tracking ref such as ;; refs/remotes/racket-git-push-.../main behind. Remove all refs owned ;; by the temporary remote before deleting its configuration. (with-handlers ([exn:fail? void]) (remove-temporary-remote-refs! repo temp-name)) (define config (git_repository_config repo)) (for ([suffix (in-list '("url" "fetch" "push"))]) (with-handlers ([exn:fail? void]) (git_config_delete_entry config (format "remote.~a.~a" temp-name suffix)))))) (progress-message quiet "[git] ~a: done" label) (void)) (define (git-push [remote-name "origin"] [branch #f] #:quiet [quiet #f]) (define actual-branch (or branch (git-current-branch))) (unless actual-branch (error 'git-push "push requires an attached local branch")) (push-refspec remote-name (format "refs/heads/~a:refs/heads/~a" actual-branch actual-branch) quiet (format "push ~a/~a" remote-name actual-branch))) (define (git-push-tag tag [remote-name "origin"] #:quiet [quiet #f]) (push-refspec remote-name (format "refs/tags/~a:refs/tags/~a" tag tag) quiet (format "push tag ~a to ~a" tag remote-name))) (define (git command #:quiet [quiet #f] . args) (unless (symbol? command) (raise-argument-error 'git "symbol?" command)) (case command [(init) (apply git-init args)] [(clone) (keyword-apply git-clone '(#:quiet) (list quiet) args)] [(status) (apply git-status args)] [(diff) (apply git-diff args)] [(add) (apply git-add args)] [(config) (apply git-config args)] [(commit) (apply git-commit args)] [(branch) (match args [(list '-d name) (git-branch-delete name)] [_ (apply git-branch args)])] [(checkout) (match args [(list '-b name) (git-checkout-new name)] [_ (apply git-checkout args)])] [(tag) (match args [(list '-d name) (git-tag-delete name)] [_ (apply git-tag args)])] [(log) (apply git-log args)] [(remote) (match args ['() (git-remotes)] [(list 'add name url) (git-remote-add name url)] [(list 'get-url name) (git-remote-url name)] [_ (error 'git "invalid remote arguments: ~e" args)])] [(fetch) (keyword-apply git-fetch '(#:quiet) (list quiet) args)] [(pull) (keyword-apply git-pull '(#:quiet) (list quiet) args)] [(push) (keyword-apply git-push '(#:quiet) (list quiet) args)] [(push-tag) (keyword-apply git-push-tag '(#:quiet) (list quiet) args)] [(credentials) (match args [(list 'init password) (git-credentials-init! password)] [(list 'unlock password) (git-credentials-unlock! password)] [(list 'unlock password seconds) (git-credentials-unlock! password #:for seconds)] [(list 'lock) (git-credentials-lock!)] [(list 'unlocked?) (git-credentials-unlocked?)] [(list 'set remote username token) (git-credentials-set! remote username token)] [(list 'configured? remote) (git-credentials-configured? remote)] [(list 'remove remote) (git-credentials-remove! remote)] [_ (error 'git "invalid credentials arguments: ~e" args)])] [else (error 'git "unknown command: ~a" command)])) (define (status-description entry) (define flags (git-status-entry-flags entry)) (cond [(has-status? flags 'GIT_STATUS_CONFLICTED) "Conflicted"] [(has-status? flags 'GIT_STATUS_WT_NEW) "New"] [(or (has-status? flags 'GIT_STATUS_INDEX_RENAMED) (has-status? flags 'GIT_STATUS_WT_RENAMED)) "Renamed"] [(or (has-status? flags 'GIT_STATUS_INDEX_DELETED) (has-status? flags 'GIT_STATUS_WT_DELETED)) "Deleted"] [(has-status? flags 'GIT_STATUS_INDEX_NEW) "Added"] [(or (has-status? flags 'GIT_STATUS_INDEX_TYPECHANGE) (has-status? flags 'GIT_STATUS_WT_TYPECHANGE)) "Type changed"] [(or (has-status? flags 'GIT_STATUS_INDEX_MODIFIED) (has-status? flags 'GIT_STATUS_WT_MODIFIED)) "Modified"] [(has-status? flags 'GIT_STATUS_WT_UNREADABLE) "Unreadable"] [else (git-status-entry-code entry)])) (define (pad-right value width) (define text (format "~a" value)) (string-append text (make-string (max 0 (- width (string-length text))) #\space))) (define (display-git-result command result [out (current-output-port)]) (cond [(eq? command 'status) (for ([entry (in-list result)]) (fprintf out "~a - ~a\n" (pad-right (status-description entry) 12) (git-status-entry-path entry)))] [(eq? command 'diff) (display result out)] [(eq? command 'log) (for ([entry (in-list result)]) (fprintf out "~a ~a\n" (substring (git-log-entry-id entry) 0 7) (git-log-entry-summary entry)))] [(list? result) (for ([item (in-list result)]) (displayln item out))] [(void? result) (void)] [else (displayln result out)])) (define (dgit command #:quiet [quiet #f] . args) (define result (keyword-apply git '(#:quiet) (list quiet) (cons command args))) (display-git-result command result) result)