Compare commits
6
Commits
a09bc5f08c
...
837ab5a0ba
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
837ab5a0ba | ||
|
|
1c77a078c4 | ||
|
|
2d45601a68 | ||
|
|
b3a998d308 | ||
|
|
fbf76e74ba | ||
|
|
efd03bd897 |
No files matched your search
@@ -6,33 +6,92 @@ A Git extension for managing repositories across multiple Git hosts and destinat
|
||||
|
||||
## Status
|
||||
|
||||
**v0.1.0: CLI foundation**
|
||||
**v0.2.0: remote management**
|
||||
|
||||
Initial release is just figuring out the project structure and command-line interface:
|
||||
- `git fleet status`, `git fleet add` and `git fleet remove`
|
||||
- Multiple push destinations on a single remote
|
||||
- Preserves the destination a remote already pushed to
|
||||
- Collapses redundant configuration back to plain Git behaviour
|
||||
- `--dry-run` on every command that changes something
|
||||
- Git is run directly, never through a shell
|
||||
|
||||
- Native OCaml executable
|
||||
- Dune build system
|
||||
- Cmdliner command interface
|
||||
- `git fleet` Git subcommand integration
|
||||
- Automated tests
|
||||
## How it works
|
||||
|
||||
Remote and multi-destination management will begin in v0.2.0.
|
||||
`git-fleet` configures ordinary Git remotes. A remote fetches from one URL and may push to several, which is how a repository stays mirrored across more than one host:
|
||||
|
||||
```ini
|
||||
[remote "origin"]
|
||||
url = git@git.example.invalid:user/project.git
|
||||
fetch = +refs/heads/*:refs/remotes/origin/*
|
||||
pushurl = git@git.example.invalid:user/project.git
|
||||
pushurl = git@github.com:user/project.git
|
||||
```
|
||||
|
||||
A remote with no `pushurl` pushes to the URL it fetches from. As soon as it has one `pushurl`, Git pushes only to the URLs listed there, so `git fleet add` writes out the existing destination before adding a new one. Removing a destination reverses that: when the only one left is the fetch URL, the redundant configuration is dropped.
|
||||
|
||||
Every command works on `origin` unless `--remote` says otherwise.
|
||||
|
||||
## Usage
|
||||
|
||||
Once `git-fleet` is installed somewhere on your `PATH`, Git automatically exposes it as a subcommand:
|
||||
Show where a remote fetches from and pushes to:
|
||||
|
||||
```sh
|
||||
git fleet status
|
||||
git fleet status --remote upstream
|
||||
```
|
||||
|
||||
```
|
||||
origin
|
||||
fetch git@git.example.invalid:user/project.git
|
||||
push git@git.example.invalid:user/project.git
|
||||
push git@github.com:user/project.git
|
||||
```
|
||||
|
||||
Add a destination. With neither `--fetch` nor `--push`, the URL is added as a push destination. A remote that does not exist yet is created:
|
||||
|
||||
```sh
|
||||
git fleet add git@github.com:user/project.git
|
||||
git fleet add git@git.example.invalid:user/project.git --fetch
|
||||
git fleet add git@git.example.invalid:user/project.git --fetch --push
|
||||
git fleet add git@github.com:user/project.git --remote upstream
|
||||
```
|
||||
|
||||
Remove a push destination. URLs are matched exactly, as they appear in `git fleet status`:
|
||||
|
||||
```sh
|
||||
git fleet remove git@github.com:user/project.git
|
||||
```
|
||||
|
||||
Preview the Git commands instead of running them:
|
||||
|
||||
```sh
|
||||
git fleet add git@github.com:user/project.git --dry-run
|
||||
```
|
||||
|
||||
```
|
||||
Would change remote 'origin':
|
||||
git remote set-url --add --push origin git@git.example.invalid:user/project.git
|
||||
git remote set-url --add --push origin git@github.com:user/project.git
|
||||
```
|
||||
|
||||
Commands exit with `1` and a single line on standard error when something is wrong, such as running outside a repository or naming a remote that does not exist.
|
||||
|
||||
## Installation
|
||||
|
||||
Build a release binary and put it on your `PATH`:
|
||||
|
||||
```sh
|
||||
dune build --release
|
||||
install -Dm755 _build/default/bin/main.exe ~/.local/bin/git-fleet
|
||||
```
|
||||
|
||||
Git exposes any `git-*` executable on `PATH` as a subcommand, so `git-fleet` becomes:
|
||||
|
||||
```sh
|
||||
git fleet --help
|
||||
git fleet --version
|
||||
```
|
||||
|
||||
You can also invoke the executable directly:
|
||||
|
||||
```sh
|
||||
git-fleet --help
|
||||
```
|
||||
|
||||
## Development
|
||||
|
||||
Install the project dependencies:
|
||||
@@ -41,24 +100,15 @@ Install the project dependencies:
|
||||
opam install . --deps-only --with-test
|
||||
```
|
||||
|
||||
Build:
|
||||
Build, test and run:
|
||||
|
||||
```sh
|
||||
dune build
|
||||
```
|
||||
|
||||
Run the tests:
|
||||
|
||||
```sh
|
||||
dune runtest
|
||||
dune exec git-fleet -- status
|
||||
```
|
||||
|
||||
Run the development executable:
|
||||
|
||||
```sh
|
||||
dune exec git-fleet -- --help
|
||||
dune exec git-fleet -- --version
|
||||
```
|
||||
The tests create temporary repositories and never change the configuration of this one.
|
||||
|
||||
## License
|
||||
|
||||
|
||||
+1
-2
@@ -1,2 +1 @@
|
||||
let () =
|
||||
exit (Cmdliner.Cmd.eval Git_fleet.Cli.cmd)
|
||||
let () = exit (Cmdliner.Cmd.eval' Git_fleet.Cli.cmd)
|
||||
+1
-1
@@ -1,7 +1,7 @@
|
||||
(lang dune 3.16)
|
||||
|
||||
(name git-fleet)
|
||||
(version 0.1.0)
|
||||
(version 0.2.0)
|
||||
|
||||
(generate_opam_files true)
|
||||
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
# This file is generated by dune, edit dune-project instead
|
||||
opam-version: "2.0"
|
||||
version: "0.1.0"
|
||||
version: "0.2.0"
|
||||
synopsis: "Manage Git repositories across multiple destinations"
|
||||
description:
|
||||
"git-fleet is a Git extension for managing repositories across multiple Git hosts and push destinations."
|
||||
|
||||
+193
-3
@@ -1,7 +1,197 @@
|
||||
open Cmdliner
|
||||
|
||||
let exit_error = 1
|
||||
|
||||
let fail message =
|
||||
prerr_endline ("git-fleet: " ^ message);
|
||||
exit_error
|
||||
|
||||
let report = function Ok () -> 0 | Error error -> fail (Git.string_of_error error)
|
||||
|
||||
let ( let* ) = Result.bind
|
||||
|
||||
let repository () =
|
||||
let cwd = Sys.getcwd () in
|
||||
if Git.is_repository ~cwd then Ok cwd else Error (Git.Not_a_repository cwd)
|
||||
|
||||
(* Only quote arguments that would otherwise be misread when pasted into a
|
||||
shell, so that ordinary Git URLs stay readable. *)
|
||||
let shell_safe = function
|
||||
| 'A' .. 'Z' | 'a' .. 'z' | '0' .. '9' | '@' | ':' | '/' | '.' | '_' | '-'
|
||||
| '+' | '~' | '=' ->
|
||||
true
|
||||
| _ -> false
|
||||
|
||||
let quote argument =
|
||||
if argument <> "" && String.for_all shell_safe argument then argument
|
||||
else Filename.quote argument
|
||||
|
||||
let print_action action =
|
||||
print_endline
|
||||
(" " ^ String.concat " " (List.map quote (Git.command_of_action action)))
|
||||
|
||||
let render_remote (remote : Git.remote) =
|
||||
let line label url = Printf.sprintf " %-5s %s" label url in
|
||||
String.concat "\n"
|
||||
((remote.name :: List.map (line "fetch") remote.fetch_urls)
|
||||
@ List.map (line "push") remote.push_urls)
|
||||
|
||||
let print_remote remote = print_endline (render_remote remote)
|
||||
|
||||
let show_remote ~cwd ~name =
|
||||
let* remote = Git.remote ~cwd ~name in
|
||||
print_remote remote;
|
||||
Ok ()
|
||||
|
||||
let run_plan ~cwd ~name ~dry_run ~unchanged plan =
|
||||
if plan = [] then (
|
||||
print_endline unchanged;
|
||||
Ok ())
|
||||
else if dry_run then (
|
||||
Printf.printf "Would change remote '%s':\n" name;
|
||||
List.iter print_action plan;
|
||||
Ok ())
|
||||
else
|
||||
let* () = Plan.execute ~cwd plan in
|
||||
show_remote ~cwd ~name
|
||||
|
||||
let remote_arg =
|
||||
let doc = "The Git remote to operate on." in
|
||||
Arg.(value & opt string "origin" & info [ "remote" ] ~docv:"REMOTE" ~doc)
|
||||
|
||||
let dry_run_arg =
|
||||
let doc = "Report the Git commands that would run, changing nothing." in
|
||||
Arg.(value & flag & info [ "dry-run" ] ~doc)
|
||||
|
||||
let url_arg ~doc = Arg.(required & pos 0 (some string) None & info [] ~docv:"URL" ~doc)
|
||||
|
||||
let status_term =
|
||||
let run name =
|
||||
report
|
||||
(let* cwd = repository () in
|
||||
show_remote ~cwd ~name)
|
||||
in
|
||||
Term.(const run $ remote_arg)
|
||||
|
||||
let status_cmd =
|
||||
let doc = "Show the destinations of a remote." in
|
||||
let man =
|
||||
[
|
||||
`S Manpage.s_description;
|
||||
`P
|
||||
"Print the fetch URL and every push destination of a remote. A remote \
|
||||
with no explicit push destination pushes to its fetch URL, and that \
|
||||
destination is shown as well.";
|
||||
`S Manpage.s_examples;
|
||||
`Pre "git fleet status";
|
||||
`Pre "git fleet status --remote upstream";
|
||||
]
|
||||
in
|
||||
Cmd.v (Cmd.info "status" ~doc ~man) status_term
|
||||
|
||||
let add_term =
|
||||
let fetch_arg =
|
||||
let doc = "Use $(i,URL) as the fetch URL of the remote." in
|
||||
Arg.(value & flag & info [ "fetch" ] ~doc)
|
||||
in
|
||||
let push_arg =
|
||||
let doc = "Add $(i,URL) as a push destination of the remote." in
|
||||
Arg.(value & flag & info [ "push" ] ~doc)
|
||||
in
|
||||
let run name fetch push dry_run url =
|
||||
report
|
||||
(let* cwd = repository () in
|
||||
let* plan = Plan.add ~cwd ~name ~fetch ~push ~url in
|
||||
let unchanged =
|
||||
Printf.sprintf "Remote '%s' already uses %s; nothing to change." name
|
||||
url
|
||||
in
|
||||
run_plan ~cwd ~name ~dry_run ~unchanged plan)
|
||||
in
|
||||
Term.(
|
||||
const run $ remote_arg $ fetch_arg $ push_arg $ dry_run_arg
|
||||
$ url_arg ~doc:"The Git URL to add.")
|
||||
|
||||
let add_cmd =
|
||||
let doc = "Add a destination to a remote." in
|
||||
let man =
|
||||
[
|
||||
`S Manpage.s_description;
|
||||
`P
|
||||
"Add a Git URL to a remote. With neither $(b,--fetch) nor $(b,--push) \
|
||||
the URL becomes a push destination.";
|
||||
`P
|
||||
"A remote that does not exist yet is created with $(i,URL) as its \
|
||||
fetch URL. When a remote gains its first explicit push destination, \
|
||||
the destination it already pushed to is kept, so that pushing \
|
||||
continues to reach it.";
|
||||
`S Manpage.s_examples;
|
||||
`Pre "git fleet add git@github.com:user/project.git";
|
||||
`Pre "git fleet add git@git.example.invalid:user/project.git --fetch --push";
|
||||
`Pre "git fleet add git@github.com:user/project.git --remote upstream";
|
||||
`Pre "git fleet add git@github.com:user/project.git --dry-run";
|
||||
]
|
||||
in
|
||||
Cmd.v (Cmd.info "add" ~doc ~man) add_term
|
||||
|
||||
let remove_term =
|
||||
let run name dry_run url =
|
||||
report
|
||||
(let* cwd = repository () in
|
||||
let* plan = Plan.remove ~cwd ~name ~url in
|
||||
let unchanged =
|
||||
Printf.sprintf "Remote '%s' does not push to %s; nothing to change."
|
||||
name url
|
||||
in
|
||||
run_plan ~cwd ~name ~dry_run ~unchanged plan)
|
||||
in
|
||||
Term.(
|
||||
const run $ remote_arg $ dry_run_arg
|
||||
$ url_arg ~doc:"The push destination to remove.")
|
||||
|
||||
let remove_cmd =
|
||||
let doc = "Remove a push destination from a remote." in
|
||||
let man =
|
||||
[
|
||||
`S Manpage.s_description;
|
||||
`P
|
||||
"Remove a push destination from a remote. The URL is matched exactly, \
|
||||
as it appears in $(b,git fleet status).";
|
||||
`P
|
||||
"When the only destination left is the remote's fetch URL, the \
|
||||
redundant configuration is removed and the remote returns to Git's \
|
||||
usual behaviour of pushing to where it fetches from.";
|
||||
`S Manpage.s_examples;
|
||||
`Pre "git fleet remove git@github.com:user/project.git";
|
||||
`Pre "git fleet remove git@github.com:user/project.git --remote upstream";
|
||||
`Pre "git fleet remove git@github.com:user/project.git --dry-run";
|
||||
]
|
||||
in
|
||||
Cmd.v (Cmd.info "remove" ~doc ~man) remove_term
|
||||
|
||||
let cmd =
|
||||
let doc = "Manage Git repositories across multiple destinations." in
|
||||
Cmd.v
|
||||
(Cmd.info "git-fleet" ~version:Version.value ~doc)
|
||||
(Term.const ())
|
||||
let man =
|
||||
[
|
||||
`S Manpage.s_description;
|
||||
`P
|
||||
"git-fleet manages the destinations of ordinary Git remotes. A remote \
|
||||
fetches from one URL and may push to several, which keeps a \
|
||||
repository mirrored across more than one Git host.";
|
||||
`P
|
||||
"Installed on $(b,PATH), git-fleet is available as $(b,git fleet). \
|
||||
Commands act on the remote $(b,origin) unless $(b,--remote) says \
|
||||
otherwise.";
|
||||
`S Manpage.s_examples;
|
||||
`Pre "git fleet status";
|
||||
`Pre "git fleet add git@github.com:user/project.git";
|
||||
`Pre "git fleet remove git@github.com:user/project.git";
|
||||
]
|
||||
in
|
||||
let exits =
|
||||
Cmd.Exit.info exit_error ~doc:"on a git-fleet error, such as an unknown remote."
|
||||
:: Cmd.Exit.defaults
|
||||
in
|
||||
let info = Cmd.info "git-fleet" ~version:Version.value ~doc ~man ~exits in
|
||||
let default = Term.ret (Term.const (`Help (`Pager, None))) in
|
||||
Cmd.group info ~default [ status_cmd; add_cmd; remove_cmd ]
|
||||
@@ -1,4 +1,4 @@
|
||||
(library
|
||||
(name git_fleet)
|
||||
(public_name git-fleet.lib)
|
||||
(libraries cmdliner))
|
||||
(libraries cmdliner unix))
|
||||
+194
@@ -0,0 +1,194 @@
|
||||
type error =
|
||||
| Git_unavailable of string
|
||||
| Not_a_repository of string
|
||||
| No_such_remote of string
|
||||
| Not_a_push_destination of { remote : string; url : string }
|
||||
| Implicit_push_destination of { remote : string; url : string }
|
||||
| Command_failed of { args : string list; status : int; stderr : string }
|
||||
|
||||
type remote = {
|
||||
name : string;
|
||||
fetch_urls : string list;
|
||||
push_urls : string list;
|
||||
explicit_push_urls : string list;
|
||||
}
|
||||
|
||||
type action =
|
||||
| Add_remote of { name : string; url : string }
|
||||
| Set_fetch_url of { name : string; url : string }
|
||||
| Add_push_url of { name : string; url : string }
|
||||
| Clear_push_urls of { name : string }
|
||||
|
||||
let executable = "git"
|
||||
|
||||
(* Read every pipe to end of file without blocking on one while the other
|
||||
fills up. *)
|
||||
let drain fds =
|
||||
let buffers = List.map (fun fd -> (fd, Buffer.create 1024)) fds in
|
||||
let chunk = Bytes.create 65536 in
|
||||
let rec loop open_fds =
|
||||
if open_fds <> [] then begin
|
||||
let ready =
|
||||
try
|
||||
let ready, _, _ = Unix.select open_fds [] [] (-1.0) in
|
||||
ready
|
||||
with Unix.Unix_error (Unix.EINTR, _, _) -> []
|
||||
in
|
||||
let finished =
|
||||
List.filter
|
||||
(fun fd ->
|
||||
match Unix.read fd chunk 0 (Bytes.length chunk) with
|
||||
| 0 -> true
|
||||
| n ->
|
||||
Buffer.add_subbytes (List.assq fd buffers) chunk 0 n;
|
||||
false
|
||||
| exception Unix.Unix_error (Unix.EINTR, _, _) -> false)
|
||||
ready
|
||||
in
|
||||
loop (List.filter (fun fd -> not (List.memq fd finished)) open_fds)
|
||||
end
|
||||
in
|
||||
loop fds;
|
||||
List.map (fun (_, buffer) -> Buffer.contents buffer) buffers
|
||||
|
||||
type output = { status : int; stdout : string; stderr : string }
|
||||
|
||||
(* Run [git] with [args] as distinct argv entries. No shell is involved. *)
|
||||
let exec args =
|
||||
let out_read, out_write = Unix.pipe ~cloexec:true () in
|
||||
let err_read, err_write = Unix.pipe ~cloexec:true () in
|
||||
let close_all () =
|
||||
List.iter
|
||||
(fun fd -> try Unix.close fd with Unix.Unix_error _ -> ())
|
||||
[ out_read; out_write; err_read; err_write ]
|
||||
in
|
||||
match
|
||||
let devnull = Unix.openfile Filename.null [ Unix.O_RDONLY ] 0 in
|
||||
Fun.protect
|
||||
~finally:(fun () -> Unix.close devnull)
|
||||
(fun () ->
|
||||
Unix.create_process executable
|
||||
(Array.of_list (executable :: args))
|
||||
devnull out_write err_write)
|
||||
with
|
||||
| exception Unix.Unix_error (code, _, _) ->
|
||||
close_all ();
|
||||
Error (Git_unavailable (Unix.error_message code))
|
||||
| pid ->
|
||||
Unix.close out_write;
|
||||
Unix.close err_write;
|
||||
let stdout, stderr =
|
||||
match drain [ out_read; err_read ] with
|
||||
| [ stdout; stderr ] -> (stdout, stderr)
|
||||
| _ -> ("", "")
|
||||
in
|
||||
Unix.close out_read;
|
||||
Unix.close err_read;
|
||||
let status =
|
||||
match snd (Unix.waitpid [] pid) with
|
||||
| Unix.WEXITED code -> code
|
||||
| Unix.WSIGNALED signal | Unix.WSTOPPED signal -> 128 + signal
|
||||
in
|
||||
Ok { status; stdout; stderr }
|
||||
|
||||
let failed args { status; stderr; _ } =
|
||||
Command_failed { args; status; stderr = String.trim stderr }
|
||||
|
||||
let in_repo ~cwd args = exec ("-C" :: cwd :: args)
|
||||
|
||||
(* Run a command that is expected to succeed, returning its standard output. *)
|
||||
let output ~cwd args =
|
||||
match in_repo ~cwd args with
|
||||
| Error _ as error -> error
|
||||
| Ok ({ status = 0; stdout; _ } : output) -> Ok stdout
|
||||
| Ok result -> Error (failed args result)
|
||||
|
||||
let lines text =
|
||||
String.split_on_char '\n' text
|
||||
|> List.map String.trim
|
||||
|> List.filter (fun line -> line <> "")
|
||||
|
||||
let unit_of_output result = Result.map (fun (_ : string) -> ()) result
|
||||
|
||||
let is_repository ~cwd =
|
||||
match in_repo ~cwd [ "rev-parse"; "--is-inside-work-tree" ] with
|
||||
| Ok { status = 0; stdout; _ } -> String.trim stdout = "true"
|
||||
| Ok _ | Error _ -> false
|
||||
|
||||
let remotes ~cwd = Result.map lines (output ~cwd [ "remote" ])
|
||||
|
||||
let remote_exists ~cwd ~name =
|
||||
Result.map (fun names -> List.mem name names) (remotes ~cwd)
|
||||
|
||||
(* [git remote get-url] fails when the remote is unknown; report that as a
|
||||
first class error rather than a raw command failure. *)
|
||||
let get_urls ~cwd ~name args =
|
||||
match in_repo ~cwd args with
|
||||
| Error _ as error -> error
|
||||
| Ok ({ status = 0; stdout; _ } : output) -> Ok (lines stdout)
|
||||
| Ok result -> (
|
||||
match remote_exists ~cwd ~name with
|
||||
| Ok false -> Error (No_such_remote name)
|
||||
| Ok true -> Error (failed args result)
|
||||
| Error _ as error -> error)
|
||||
|
||||
let get_fetch_urls ~cwd ~name =
|
||||
get_urls ~cwd ~name [ "remote"; "get-url"; "--all"; name ]
|
||||
|
||||
let get_push_urls ~cwd ~name =
|
||||
get_urls ~cwd ~name [ "remote"; "get-url"; "--push"; "--all"; name ]
|
||||
|
||||
let get_explicit_push_urls ~cwd ~name =
|
||||
let key = "remote." ^ name ^ ".pushurl" in
|
||||
let args = [ "config"; "--get-all"; key ] in
|
||||
match in_repo ~cwd args with
|
||||
| Error _ as error -> error
|
||||
| Ok ({ status = 0; stdout; _ } : output) -> Ok (lines stdout)
|
||||
(* Exit status 1 means the key is simply not set. *)
|
||||
| Ok { status = 1; _ } -> Ok []
|
||||
| Ok result -> Error (failed args result)
|
||||
|
||||
let ( let* ) = Result.bind
|
||||
|
||||
let remote ~cwd ~name =
|
||||
let* fetch_urls = get_fetch_urls ~cwd ~name in
|
||||
let* push_urls = get_push_urls ~cwd ~name in
|
||||
let* explicit_push_urls = get_explicit_push_urls ~cwd ~name in
|
||||
Ok { name; fetch_urls; push_urls; explicit_push_urls }
|
||||
|
||||
let command_of_action = function
|
||||
| Add_remote { name; url } -> [ "git"; "remote"; "add"; name; url ]
|
||||
| Set_fetch_url { name; url } -> [ "git"; "remote"; "set-url"; name; url ]
|
||||
| Add_push_url { name; url } ->
|
||||
[ "git"; "remote"; "set-url"; "--add"; "--push"; name; url ]
|
||||
| Clear_push_urls { name } ->
|
||||
[ "git"; "config"; "--unset-all"; "remote." ^ name ^ ".pushurl" ]
|
||||
|
||||
let apply ~cwd action =
|
||||
let args = List.tl (command_of_action action) in
|
||||
match action with
|
||||
| Clear_push_urls _ -> (
|
||||
match in_repo ~cwd args with
|
||||
| Error _ as error -> error
|
||||
(* Status 5 means there was nothing to unset. *)
|
||||
| Ok ({ status = 0 | 5; _ } : output) -> Ok ()
|
||||
| Ok result -> Error (failed args result))
|
||||
| Add_remote _ | Set_fetch_url _ | Add_push_url _ ->
|
||||
unit_of_output (output ~cwd args)
|
||||
|
||||
let string_of_error = function
|
||||
| Git_unavailable reason -> "could not run git: " ^ reason
|
||||
| Not_a_repository cwd -> "not a Git repository: " ^ cwd
|
||||
| No_such_remote name -> "no such remote: " ^ name
|
||||
| Not_a_push_destination { remote; url } ->
|
||||
Printf.sprintf "%s is not a push destination of remote '%s'" url remote
|
||||
| Implicit_push_destination { remote; url } ->
|
||||
Printf.sprintf
|
||||
"%s is the implicit push destination of remote '%s' and follows its \
|
||||
fetch URL; change the fetch URL instead"
|
||||
url remote
|
||||
| Command_failed { args; status; stderr } ->
|
||||
let command = String.concat " " ("git" :: args) in
|
||||
if stderr = "" then
|
||||
Printf.sprintf "%s exited with status %d" command status
|
||||
else Printf.sprintf "%s: %s" command stderr
|
||||
+64
@@ -0,0 +1,64 @@
|
||||
(** Execution of [git] subprocesses and inspection of repository remotes.
|
||||
|
||||
Every operation runs the [git] executable directly. User supplied values,
|
||||
such as URLs, are always passed as separate arguments and are never
|
||||
interpolated into a shell command line. *)
|
||||
|
||||
(** Failures that are expected during normal use of the command line. *)
|
||||
type error =
|
||||
| Git_unavailable of string
|
||||
(** The [git] executable could not be started. *)
|
||||
| Not_a_repository of string (** The directory is not a Git work tree. *)
|
||||
| No_such_remote of string (** The repository has no such remote. *)
|
||||
| Not_a_push_destination of { remote : string; url : string }
|
||||
(** The URL is not configured as a push destination of the remote. *)
|
||||
| Implicit_push_destination of { remote : string; url : string }
|
||||
(** The URL is the remote's implicit push destination, which follows its
|
||||
fetch URL and so cannot be removed on its own. *)
|
||||
| Command_failed of { args : string list; status : int; stderr : string }
|
||||
(** A [git] command exited with a non-zero status. *)
|
||||
|
||||
val string_of_error : error -> string
|
||||
(** A concise single line description, without a trailing newline. *)
|
||||
|
||||
(** A Git remote.
|
||||
|
||||
[push_urls] are the destinations Git actually pushes to. When a remote has
|
||||
no [remote.<name>.pushurl] entries, Git pushes to the fetch URL, so
|
||||
[explicit_push_urls] is empty while [push_urls] is not. *)
|
||||
type remote = {
|
||||
name : string;
|
||||
fetch_urls : string list;
|
||||
push_urls : string list;
|
||||
explicit_push_urls : string list;
|
||||
}
|
||||
|
||||
val is_repository : cwd:string -> bool
|
||||
(** Whether [cwd] is inside a Git work tree. *)
|
||||
|
||||
val remotes : cwd:string -> (string list, error) result
|
||||
(** The names of every configured remote, in Git's order. *)
|
||||
|
||||
val remote_exists : cwd:string -> name:string -> (bool, error) result
|
||||
val get_fetch_urls : cwd:string -> name:string -> (string list, error) result
|
||||
|
||||
val get_push_urls : cwd:string -> name:string -> (string list, error) result
|
||||
(** The effective push destinations, including the implicit one. *)
|
||||
|
||||
val get_explicit_push_urls :
|
||||
cwd:string -> name:string -> (string list, error) result
|
||||
(** The [remote.<name>.pushurl] entries, in configuration order. *)
|
||||
|
||||
val remote : cwd:string -> name:string -> (remote, error) result
|
||||
|
||||
(** A single change to a repository's configuration. *)
|
||||
type action =
|
||||
| Add_remote of { name : string; url : string }
|
||||
| Set_fetch_url of { name : string; url : string }
|
||||
| Add_push_url of { name : string; url : string }
|
||||
| Clear_push_urls of { name : string }
|
||||
|
||||
val command_of_action : action -> string list
|
||||
(** The equivalent Git command line, as an argument list starting with ["git"]. *)
|
||||
|
||||
val apply : cwd:string -> action -> (unit, error) result
|
||||
+67
@@ -0,0 +1,67 @@
|
||||
type t = Git.action list
|
||||
|
||||
let ( let* ) = Result.bind
|
||||
|
||||
(* Plan whatever is needed for [url] to become one of the remote's effective
|
||||
push destinations, leaving the existing destinations in place.
|
||||
|
||||
Git stops using the fetch URL as soon as a remote has an explicit pushurl,
|
||||
so the implicit destination has to be written out before the first explicit
|
||||
one is added. *)
|
||||
let ensure_push_destination ~name ~explicit ~effective ~url =
|
||||
let add url = Git.Add_push_url { name; url } in
|
||||
if List.mem url explicit then []
|
||||
else if explicit <> [] then [ add url ]
|
||||
else if effective = [ url ] then []
|
||||
else List.map add effective @ [ add url ]
|
||||
|
||||
let add ~cwd ~name ~fetch ~push ~url =
|
||||
(* With neither flag the URL is a push destination. *)
|
||||
let want_fetch = fetch and want_push = push || not fetch in
|
||||
let* exists = Git.remote_exists ~cwd ~name in
|
||||
if not exists then
|
||||
(* A new remote fetches and, implicitly, pushes to its only URL. *)
|
||||
Ok [ Git.Add_remote { name; url } ]
|
||||
else
|
||||
let* remote = Git.remote ~cwd ~name in
|
||||
let fetch_actions =
|
||||
if want_fetch && remote.fetch_urls <> [ url ] then
|
||||
[ Git.Set_fetch_url { name; url } ]
|
||||
else []
|
||||
in
|
||||
let push_actions =
|
||||
if not want_push then []
|
||||
else
|
||||
(* A changed fetch URL also moves the implicit push destination. *)
|
||||
let effective =
|
||||
if fetch_actions <> [] && remote.explicit_push_urls = [] then [ url ]
|
||||
else remote.push_urls
|
||||
in
|
||||
ensure_push_destination ~name ~explicit:remote.explicit_push_urls
|
||||
~effective ~url
|
||||
in
|
||||
Ok (fetch_actions @ push_actions)
|
||||
|
||||
let remove ~cwd ~name ~url =
|
||||
let* remote = Git.remote ~cwd ~name in
|
||||
if not (List.mem url remote.explicit_push_urls) then
|
||||
if remote.explicit_push_urls = [] && List.mem url remote.push_urls then
|
||||
Error (Git.Implicit_push_destination { remote = name; url })
|
||||
else Error (Git.Not_a_push_destination { remote = name; url })
|
||||
else
|
||||
let remaining =
|
||||
List.filter (fun existing -> existing <> url) remote.explicit_push_urls
|
||||
in
|
||||
let clear = Git.Clear_push_urls { name } in
|
||||
(* Dropping every explicit entry, or keeping only the fetch URL, restores
|
||||
Git's implicit push behaviour. *)
|
||||
if remaining = [] || remaining = remote.fetch_urls then Ok [ clear ]
|
||||
else
|
||||
Ok (clear :: List.map (fun url -> Git.Add_push_url { name; url }) remaining)
|
||||
|
||||
let execute ~cwd plan =
|
||||
List.fold_left
|
||||
(fun result action ->
|
||||
let* () = result in
|
||||
Git.apply ~cwd action)
|
||||
(Ok ()) plan
|
||||
@@ -0,0 +1,33 @@
|
||||
(** Working out which Git actions a request needs.
|
||||
|
||||
Planning is separated from execution so that a dry run and a real run
|
||||
describe exactly the same changes. A plan is applied in order; an empty
|
||||
plan means the repository is already in the requested state. *)
|
||||
|
||||
type t = Git.action list
|
||||
|
||||
val add :
|
||||
cwd:string ->
|
||||
name:string ->
|
||||
fetch:bool ->
|
||||
push:bool ->
|
||||
url:string ->
|
||||
(t, Git.error) result
|
||||
(** [add ~cwd ~name ~fetch ~push ~url] plans the addition of [url] to remote
|
||||
[name] as a fetch URL, a push destination, or both.
|
||||
|
||||
The remote is created when it does not exist yet. When the first explicit
|
||||
push destination is added, the remote's current implicit destination is
|
||||
recorded first, so that Git keeps pushing where it already pushed. Adding
|
||||
a destination that is already in use plans nothing. *)
|
||||
|
||||
val remove : cwd:string -> name:string -> url:string -> (t, Git.error) result
|
||||
(** [remove ~cwd ~name ~url] plans the removal of the push destination [url]
|
||||
from remote [name]. URLs are matched exactly.
|
||||
|
||||
When the only remaining explicit destination is the remote's fetch URL,
|
||||
the redundant entries are dropped so that the remote returns to Git's
|
||||
implicit push behaviour. *)
|
||||
|
||||
val execute : cwd:string -> t -> (unit, Git.error) result
|
||||
(** Apply every action in order, stopping at the first failure. *)
|
||||
+1
-1
@@ -1 +1 @@
|
||||
let value = "0.1.0"
|
||||
let value = "0.2.0"
|
||||
@@ -1,3 +1,3 @@
|
||||
(test
|
||||
(name test_git_fleet)
|
||||
(libraries git_fleet alcotest))
|
||||
(libraries git_fleet alcotest unix))
|
||||
+252
-11
@@ -1,18 +1,259 @@
|
||||
let test_version () =
|
||||
open Git_fleet
|
||||
|
||||
let primary = "git@git.example.invalid:user/project.git"
|
||||
let github = "git@github.com:user/project.git"
|
||||
let backup = "git@backup.example.invalid:user/project.git"
|
||||
let urls = Alcotest.(list string)
|
||||
|
||||
let ok = function
|
||||
| Ok value -> value
|
||||
| Error error ->
|
||||
Alcotest.failf "unexpected error: %s" (Git.string_of_error error)
|
||||
|
||||
let error = function
|
||||
| Ok _ -> Alcotest.fail "expected an error"
|
||||
| Error error -> error
|
||||
|
||||
(* Arrange repository state with Git itself, so that the assertions do not
|
||||
depend on the code under test. *)
|
||||
let git_exn dir args =
|
||||
let argv = Array.of_list (("git" :: "-C" :: dir :: args)) in
|
||||
let input = Unix.openfile Filename.null [ Unix.O_RDONLY ] 0 in
|
||||
let output = Unix.openfile Filename.null [ Unix.O_WRONLY ] 0 in
|
||||
let pid = Unix.create_process "git" argv input output output in
|
||||
let _, status = Unix.waitpid [] pid in
|
||||
Unix.close input;
|
||||
Unix.close output;
|
||||
match status with
|
||||
| Unix.WEXITED 0 -> ()
|
||||
| _ -> Alcotest.failf "git %s failed" (String.concat " " args)
|
||||
|
||||
let rec remove_tree path =
|
||||
match Unix.lstat path with
|
||||
| exception Unix.Unix_error (Unix.ENOENT, _, _) -> ()
|
||||
| { st_kind = Unix.S_DIR; _ } ->
|
||||
Array.iter
|
||||
(fun entry -> remove_tree (Filename.concat path entry))
|
||||
(Sys.readdir path);
|
||||
Unix.rmdir path
|
||||
| _ -> Unix.unlink path
|
||||
|
||||
(* Temporary directories only: the git-fleet repository itself is never
|
||||
touched by the tests. *)
|
||||
let with_dir f =
|
||||
let dir = Filename.temp_dir "git-fleet-test" "" in
|
||||
Fun.protect ~finally:(fun () -> remove_tree dir) (fun () -> f dir)
|
||||
|
||||
let with_repo f =
|
||||
with_dir (fun dir ->
|
||||
git_exn dir [ "init"; "-q" ];
|
||||
f dir)
|
||||
|
||||
let with_origin f =
|
||||
with_repo (fun cwd ->
|
||||
git_exn cwd [ "remote"; "add"; "origin"; primary ];
|
||||
f cwd)
|
||||
|
||||
let add ?(name = "origin") ?(fetch = false) ?(push = false) ~cwd url =
|
||||
let plan = ok (Plan.add ~cwd ~name ~fetch ~push ~url) in
|
||||
ok (Plan.execute ~cwd plan);
|
||||
plan
|
||||
|
||||
let remove ?(name = "origin") ~cwd url =
|
||||
let plan = ok (Plan.remove ~cwd ~name ~url) in
|
||||
ok (Plan.execute ~cwd plan);
|
||||
plan
|
||||
|
||||
let explicit ?(name = "origin") cwd = ok (Git.get_explicit_push_urls ~cwd ~name)
|
||||
let effective ?(name = "origin") cwd = ok (Git.get_push_urls ~cwd ~name)
|
||||
let fetch_urls ?(name = "origin") cwd = ok (Git.get_fetch_urls ~cwd ~name)
|
||||
|
||||
let test_not_a_repository () =
|
||||
with_dir (fun dir ->
|
||||
Alcotest.(check bool) "outside a work tree" false (Git.is_repository ~cwd:dir))
|
||||
|
||||
let test_empty_repository () =
|
||||
with_repo (fun cwd ->
|
||||
Alcotest.(check bool) "inside a work tree" true (Git.is_repository ~cwd);
|
||||
Alcotest.(check urls) "no remotes" [] (ok (Git.remotes ~cwd)))
|
||||
|
||||
let test_create_origin () =
|
||||
with_repo (fun cwd ->
|
||||
let plan = add ~cwd primary in
|
||||
Alcotest.(check int) "one action" 1 (List.length plan);
|
||||
Alcotest.(check urls) "remotes" [ "origin" ] (ok (Git.remotes ~cwd));
|
||||
Alcotest.(check urls) "fetch" [ primary ] (fetch_urls cwd);
|
||||
Alcotest.(check urls) "effective push" [ primary ] (effective cwd);
|
||||
Alcotest.(check urls) "no explicit push" [] (explicit cwd))
|
||||
|
||||
let test_custom_remote_name () =
|
||||
with_repo (fun cwd ->
|
||||
ignore (add ~cwd ~name:"upstream" primary);
|
||||
Alcotest.(check urls) "remotes" [ "upstream" ] (ok (Git.remotes ~cwd));
|
||||
Alcotest.(check urls) "fetch" [ primary ] (fetch_urls ~name:"upstream" cwd))
|
||||
|
||||
let test_change_fetch_url () =
|
||||
with_origin (fun cwd ->
|
||||
ignore (add ~cwd ~fetch:true backup);
|
||||
Alcotest.(check urls) "fetch" [ backup ] (fetch_urls cwd);
|
||||
Alcotest.(check urls) "no explicit push" [] (explicit cwd);
|
||||
Alcotest.(check urls) "push follows fetch" [ backup ] (effective cwd))
|
||||
|
||||
let test_first_push_url_preserves_implicit () =
|
||||
with_origin (fun cwd ->
|
||||
ignore (add ~cwd github);
|
||||
Alcotest.(check urls)
|
||||
"implicit destination kept first" [ primary; github ] (explicit cwd);
|
||||
Alcotest.(check urls) "effective push" [ primary; github ] (effective cwd);
|
||||
Alcotest.(check urls) "fetch unchanged" [ primary ] (fetch_urls cwd))
|
||||
|
||||
let test_add_second_push_url () =
|
||||
with_origin (fun cwd ->
|
||||
ignore (add ~cwd github);
|
||||
ignore (add ~cwd backup);
|
||||
Alcotest.(check urls)
|
||||
"three destinations" [ primary; github; backup ] (explicit cwd))
|
||||
|
||||
let test_duplicate_push_url_is_idempotent () =
|
||||
with_origin (fun cwd ->
|
||||
ignore (add ~cwd github);
|
||||
let before = explicit cwd in
|
||||
let plan = add ~cwd github in
|
||||
Alcotest.(check int) "nothing planned" 0 (List.length plan);
|
||||
Alcotest.(check urls) "configuration unchanged" before (explicit cwd))
|
||||
|
||||
let test_implicit_destination_is_idempotent () =
|
||||
with_origin (fun cwd ->
|
||||
let plan = add ~cwd primary in
|
||||
Alcotest.(check int) "nothing planned" 0 (List.length plan);
|
||||
Alcotest.(check urls) "still implicit" [] (explicit cwd))
|
||||
|
||||
let test_remove_push_url () =
|
||||
with_origin (fun cwd ->
|
||||
ignore (add ~cwd github);
|
||||
ignore (add ~cwd backup);
|
||||
ignore (remove ~cwd github);
|
||||
Alcotest.(check urls) "order preserved" [ primary; backup ] (explicit cwd);
|
||||
Alcotest.(check urls) "fetch unchanged" [ primary ] (fetch_urls cwd))
|
||||
|
||||
let test_remove_collapses_to_implicit () =
|
||||
with_origin (fun cwd ->
|
||||
ignore (add ~cwd github);
|
||||
ignore (remove ~cwd github);
|
||||
Alcotest.(check urls) "redundant entry removed" [] (explicit cwd);
|
||||
Alcotest.(check urls) "pushes where it fetches" [ primary ] (effective cwd))
|
||||
|
||||
let test_remove_unknown_url () =
|
||||
with_origin (fun cwd ->
|
||||
ignore (add ~cwd github);
|
||||
match error (Plan.remove ~cwd ~name:"origin" ~url:backup) with
|
||||
| Git.Not_a_push_destination { remote = "origin"; url } ->
|
||||
Alcotest.(check string) "reported url" backup url
|
||||
| other -> Alcotest.failf "unexpected error: %s" (Git.string_of_error other))
|
||||
|
||||
let test_remove_implicit_destination () =
|
||||
with_origin (fun cwd ->
|
||||
match error (Plan.remove ~cwd ~name:"origin" ~url:primary) with
|
||||
| Git.Implicit_push_destination { remote = "origin"; url } ->
|
||||
Alcotest.(check string) "reported url" primary url
|
||||
| other -> Alcotest.failf "unexpected error: %s" (Git.string_of_error other))
|
||||
|
||||
let test_unknown_remote () =
|
||||
with_origin (fun cwd ->
|
||||
Alcotest.(check bool)
|
||||
"remote_exists" false
|
||||
(ok (Git.remote_exists ~cwd ~name:"upstream"));
|
||||
match error (Git.remote ~cwd ~name:"upstream") with
|
||||
| Git.No_such_remote name -> Alcotest.(check string) "name" "upstream" name
|
||||
| other -> Alcotest.failf "unexpected error: %s" (Git.string_of_error other))
|
||||
|
||||
let test_planning_changes_nothing () =
|
||||
with_origin (fun cwd ->
|
||||
let plan = ok (Plan.add ~cwd ~name:"origin" ~fetch:false ~push:true ~url:github) in
|
||||
Alcotest.(check int) "two actions planned" 2 (List.length plan);
|
||||
Alcotest.(check urls) "no explicit push url written" [] (explicit cwd);
|
||||
Alcotest.(check urls) "fetch untouched" [ primary ] (fetch_urls cwd);
|
||||
Alcotest.(check (list string))
|
||||
"equivalent git commands"
|
||||
[
|
||||
"git remote set-url --add --push origin " ^ primary;
|
||||
"git remote set-url --add --push origin " ^ github;
|
||||
]
|
||||
(List.map
|
||||
(fun action -> String.concat " " (Git.command_of_action action))
|
||||
plan))
|
||||
|
||||
let test_status_representation () =
|
||||
with_origin (fun cwd ->
|
||||
ignore (add ~cwd github);
|
||||
let remote = ok (Git.remote ~cwd ~name:"origin") in
|
||||
Alcotest.(check string)
|
||||
"version"
|
||||
"0.1.0"
|
||||
Git_fleet.Version.value
|
||||
"rendered status"
|
||||
(String.concat "\n"
|
||||
[
|
||||
"origin";
|
||||
" fetch " ^ primary;
|
||||
" push " ^ primary;
|
||||
" push " ^ github;
|
||||
])
|
||||
(Cli.render_remote remote))
|
||||
|
||||
(* A URL that would run a command, or lose its meaning, if git-fleet built
|
||||
shell command lines out of user input. *)
|
||||
let test_hostile_url () =
|
||||
with_repo (fun cwd ->
|
||||
let marker = Filename.concat cwd "pwned" in
|
||||
let hostile =
|
||||
Printf.sprintf "ssh://git@example.invalid/a b;$(touch %s)`touch %s`.git"
|
||||
marker marker
|
||||
in
|
||||
ignore (add ~cwd primary);
|
||||
ignore (add ~cwd hostile);
|
||||
Alcotest.(check bool) "no command was executed" false (Sys.file_exists marker);
|
||||
Alcotest.(check urls) "stored verbatim" [ primary; hostile ] (explicit cwd);
|
||||
ignore (remove ~cwd hostile);
|
||||
Alcotest.(check urls) "removed exactly" [] (explicit cwd);
|
||||
Alcotest.(check urls) "fetch url intact" [ primary ] (fetch_urls cwd))
|
||||
|
||||
let test_version () =
|
||||
Alcotest.(check string) "version" "0.2.0" Version.value
|
||||
|
||||
let case name f = Alcotest.test_case name `Quick f
|
||||
|
||||
let () =
|
||||
Alcotest.run
|
||||
"git-fleet"
|
||||
(* Keep the developer's own Git configuration, such as url rewrites, out of
|
||||
the results. *)
|
||||
Unix.putenv "GIT_CONFIG_GLOBAL" Filename.null;
|
||||
Unix.putenv "GIT_CONFIG_SYSTEM" Filename.null;
|
||||
Alcotest.run "git-fleet"
|
||||
[
|
||||
( "version",
|
||||
( "repository",
|
||||
[
|
||||
Alcotest.test_case
|
||||
"reports 0.1.0"
|
||||
`Quick
|
||||
test_version;
|
||||
case "outside a repository" test_not_a_repository;
|
||||
case "empty repository" test_empty_repository;
|
||||
case "unknown remote" test_unknown_remote;
|
||||
] );
|
||||
( "fetch urls",
|
||||
[
|
||||
case "creates origin" test_create_origin;
|
||||
case "custom remote name" test_custom_remote_name;
|
||||
case "changes the fetch url" test_change_fetch_url;
|
||||
] );
|
||||
( "push destinations",
|
||||
[
|
||||
case "first push url preserves the implicit one"
|
||||
test_first_push_url_preserves_implicit;
|
||||
case "adds a second push url" test_add_second_push_url;
|
||||
case "duplicate push url is idempotent" test_duplicate_push_url_is_idempotent;
|
||||
case "implicit destination is idempotent" test_implicit_destination_is_idempotent;
|
||||
case "removes a push url" test_remove_push_url;
|
||||
case "collapses to implicit push" test_remove_collapses_to_implicit;
|
||||
case "removing an unconfigured url fails" test_remove_unknown_url;
|
||||
case "removing the implicit destination fails" test_remove_implicit_destination;
|
||||
case "urls are never interpreted by a shell" test_hostile_url;
|
||||
] );
|
||||
( "planning",
|
||||
[ case "planning does not touch the repository" test_planning_changes_nothing ] );
|
||||
( "status", [ case "renders a remote" test_status_representation ] );
|
||||
( "version", [ case "reports 0.2.0" test_version ] );
|
||||
]
|
||||
Reference in new issue
Block a user