From b0eb1b379acec0b034ae7887aaffc63ded6f75f1 Mon Sep 17 00:00:00 2001 From: David Gardiner Date: Tue, 27 Sep 2022 11:34:30 -0700 Subject: [PATCH 01/44] Implement gRPC client --- go.mod | 3 + go.sum | 6 + pkg/grpc/client.go | 76 ++++++ .../jupyter/JupyterServerHostService.v1.pb.go | 241 ++++++++++++++++++ .../jupyter/JupyterServerHostService.v1.proto | 19 ++ .../JupyterServerHostService.v1_grpc.pb.go | 105 ++++++++ pkg/liveshare/client.go | 41 +++ pkg/liveshare/session.go | 28 +- 8 files changed, 496 insertions(+), 23 deletions(-) create mode 100644 pkg/grpc/client.go create mode 100644 pkg/grpc/jupyter/JupyterServerHostService.v1.pb.go create mode 100644 pkg/grpc/jupyter/JupyterServerHostService.v1.proto create mode 100644 pkg/grpc/jupyter/JupyterServerHostService.v1_grpc.pb.go diff --git a/go.mod b/go.mod index 08d4c245b..67d18003b 100644 --- a/go.mod +++ b/go.mod @@ -38,6 +38,8 @@ require ( golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab golang.org/x/term v0.0.0-20210927222741-03fcf44c2211 golang.org/x/text v0.3.7 + google.golang.org/grpc v1.49.0 + google.golang.org/protobuf v1.27.1 gopkg.in/yaml.v3 v3.0.1 ) @@ -70,6 +72,7 @@ require ( github.com/yuin/goldmark-emoji v1.0.1 // indirect golang.org/x/net v0.0.0-20220722155237-a158d28d115b // indirect golang.org/x/oauth2 v0.0.0-20220309155454-6242fa91716a // indirect + google.golang.org/genproto v0.0.0-20200825200019-8632dd797987 // indirect gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15 // indirect ) diff --git a/go.sum b/go.sum index f24d36e0a..4b79794c5 100644 --- a/go.sum +++ b/go.sum @@ -485,6 +485,7 @@ google.golang.org/genproto v0.0.0-20200526211855-cb27e3aa2013/go.mod h1:NbSheEEY google.golang.org/genproto v0.0.0-20200618031413-b414f8b61790/go.mod h1:jDfRM7FcilCzHH/e9qn6dsT145K34l5v+OpcnNgKAAA= google.golang.org/genproto v0.0.0-20200729003335-053ba62fc06f/go.mod h1:FWY/as6DDZQgahTzZj3fqbO1CbirC29ZNUFHwi0/+no= google.golang.org/genproto v0.0.0-20200804131852-c06518451d9c/go.mod h1:FWY/as6DDZQgahTzZj3fqbO1CbirC29ZNUFHwi0/+no= +google.golang.org/genproto v0.0.0-20200825200019-8632dd797987 h1:PDIOdWxZ8eRizhKa1AAvY53xsvLB1cWorMjslvY3VA8= google.golang.org/genproto v0.0.0-20200825200019-8632dd797987/go.mod h1:FWY/as6DDZQgahTzZj3fqbO1CbirC29ZNUFHwi0/+no= google.golang.org/grpc v1.19.0/go.mod h1:mqu4LbDTu4XGKhr4mRzUsmM4RtVoemTSY81AxZiDr8c= google.golang.org/grpc v1.20.1/go.mod h1:10oTOabMzJvdu6/UiuZezV6QK5dSlG84ov/aaiqXj38= @@ -497,7 +498,10 @@ google.golang.org/grpc v1.27.1/go.mod h1:qbnxyOmOxrQa7FizSgH+ReBfzJrCY1pSN7KXBS8 google.golang.org/grpc v1.28.0/go.mod h1:rpkK4SK4GF4Ach/+MFLZUBavHOvF2JJB5uozKKal+60= google.golang.org/grpc v1.29.1/go.mod h1:itym6AZVZYACWQqET3MqgPpjcuV5QH3BxFS3IjizoKk= google.golang.org/grpc v1.30.0/go.mod h1:N36X2cJ7JwdamYAgDz+s+rVMFjt3numwzf/HckM8pak= +google.golang.org/grpc v1.31.0 h1:T7P4R73V3SSDPhH7WW7ATbfViLtmamH0DKrP3f9AuDI= google.golang.org/grpc v1.31.0/go.mod h1:N36X2cJ7JwdamYAgDz+s+rVMFjt3numwzf/HckM8pak= +google.golang.org/grpc v1.49.0 h1:WTLtQzmQori5FUH25Pq4WT22oCsv8USpQ+F6rqtsmxw= +google.golang.org/grpc v1.49.0/go.mod h1:ZgQEeidpAuNRZ8iRrlBKXZQP1ghovWIVhdJRyCDK+GI= google.golang.org/protobuf v0.0.0-20200109180630-ec00e32a8dfd/go.mod h1:DFci5gLYBciE7Vtevhsrf46CRTquxDuWsQurQQe4oz8= google.golang.org/protobuf v0.0.0-20200221191635-4d8936d0db64/go.mod h1:kwYJMbMJ01Woi6D6+Kah6886xMZcty6N08ah7+eCXa0= google.golang.org/protobuf v0.0.0-20200228230310-ab0ca4ff8a60/go.mod h1:cfTl7dwQJ+fmap5saPgwCLgHXTUD7jkjRqWcaiX5VyM= @@ -511,6 +515,8 @@ google.golang.org/protobuf v1.25.0/go.mod h1:9JNX74DMeImyA3h4bdi1ymwjUzf21/xIlba google.golang.org/protobuf v1.26.0-rc.1/go.mod h1:jlhhOSvTdKEhbULTjvd4ARK9grFBp09yW+WbY/TyQbw= google.golang.org/protobuf v1.26.0 h1:bxAC2xTBsZGibn2RTntX0oH50xLsqy1OxA9tTL3p/lk= google.golang.org/protobuf v1.26.0/go.mod h1:9q0QmTI4eRPtz6boOQmLYwt+qCgq0jsYwAQnmE0givc= +google.golang.org/protobuf v1.27.1 h1:SnqbnDw1V7RiZcXPx5MEeqPv2s79L9i7BJUlG/+RurQ= +google.golang.org/protobuf v1.27.1/go.mod h1:9q0QmTI4eRPtz6boOQmLYwt+qCgq0jsYwAQnmE0givc= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15 h1:YR8cESwS4TdDjEe65xsg0ogRM/Nc3DYOhEAlW+xobZo= diff --git a/pkg/grpc/client.go b/pkg/grpc/client.go new file mode 100644 index 000000000..ddc50bb13 --- /dev/null +++ b/pkg/grpc/client.go @@ -0,0 +1,76 @@ +package grpc + +// gRPC client implementation to be able to connect to the gRPC server and perform the following operations: +// - Start a remote JupyterLab server + +import ( + "context" + "fmt" + "strconv" + "time" + + "github.com/cli/cli/v2/pkg/grpc/jupyter" + "google.golang.org/grpc" + "google.golang.org/grpc/credentials/insecure" + "google.golang.org/grpc/metadata" +) + +const ( + requestTimeout = 30 * time.Second +) + +type GrpcClient struct { + conn *grpc.ClientConn + token string + jupyterClient jupyter.JupyterServerHostClient +} + +func New() *GrpcClient { + return &GrpcClient{} +} + +// Connects to the gRPC server on the given port +func (g *GrpcClient) Connect(ctx context.Context, port int, token string) error { + // Attempt to connect to the given port + conn, err := grpc.Dial(fmt.Sprintf("localhost:%d", port), grpc.WithTransportCredentials(insecure.NewCredentials()), grpc.WithBlock()) + + if err != nil { + return fmt.Errorf("Failed to connect to the internal server on port %d", port) + } + + g.conn = conn + g.token = token + g.jupyterClient = jupyter.NewJupyterServerHostClient(conn) + + return nil +} + +// Appends the authentication token to the gRPC context +func (g *GrpcClient) appendMetadata(ctx context.Context) context.Context { + return metadata.AppendToOutgoingContext(ctx, "Authorization", "Bearer "+g.token) +} + +// Starts a remote JupyterLab server to allow the user to connect to the codespace via JupyterLab in their browser +func (g *GrpcClient) GetRunningServer() (int, string, error) { + ctx, cancel := context.WithTimeout(context.Background(), requestTimeout) + ctx = g.appendMetadata(ctx) + defer cancel() + + response, err := g.jupyterClient.GetRunningServer(ctx, &jupyter.GetRunningServerRequest{}) + + if err != nil { + return 0, "", fmt.Errorf("failed to invoke JupyterLab RPC: %w", err) + } + + if !response.Result { + return 0, "", fmt.Errorf("failed to start JupyterLab: %s", response.Message) + } + + port, err := strconv.Atoi(response.Port) + + if err != nil { + return 0, "", fmt.Errorf("failed to parse JupyterLab port: %w", err) + } + + return port, response.ServerUrl, err +} diff --git a/pkg/grpc/jupyter/JupyterServerHostService.v1.pb.go b/pkg/grpc/jupyter/JupyterServerHostService.v1.pb.go new file mode 100644 index 000000000..c48f3d0cf --- /dev/null +++ b/pkg/grpc/jupyter/JupyterServerHostService.v1.pb.go @@ -0,0 +1,241 @@ +// Code generated by protoc-gen-go. DO NOT EDIT. +// versions: +// protoc-gen-go v1.28.0 +// protoc v3.21.3 +// source: JupyterServerHostService.v1.proto + +package jupyter + +import ( + protoreflect "google.golang.org/protobuf/reflect/protoreflect" + protoimpl "google.golang.org/protobuf/runtime/protoimpl" + reflect "reflect" + sync "sync" +) + +const ( + // Verify that this generated code is sufficiently up-to-date. + _ = protoimpl.EnforceVersion(20 - protoimpl.MinVersion) + // Verify that runtime/protoimpl is sufficiently up-to-date. + _ = protoimpl.EnforceVersion(protoimpl.MaxVersion - 20) +) + +type GetRunningServerRequest struct { + state protoimpl.MessageState + sizeCache protoimpl.SizeCache + unknownFields protoimpl.UnknownFields +} + +func (x *GetRunningServerRequest) Reset() { + *x = GetRunningServerRequest{} + if protoimpl.UnsafeEnabled { + mi := &file_JupyterServerHostService_v1_proto_msgTypes[0] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) + } +} + +func (x *GetRunningServerRequest) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*GetRunningServerRequest) ProtoMessage() {} + +func (x *GetRunningServerRequest) ProtoReflect() protoreflect.Message { + mi := &file_JupyterServerHostService_v1_proto_msgTypes[0] + if protoimpl.UnsafeEnabled && x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use GetRunningServerRequest.ProtoReflect.Descriptor instead. +func (*GetRunningServerRequest) Descriptor() ([]byte, []int) { + return file_JupyterServerHostService_v1_proto_rawDescGZIP(), []int{0} +} + +type GetRunningServerResponse struct { + state protoimpl.MessageState + sizeCache protoimpl.SizeCache + unknownFields protoimpl.UnknownFields + + Result bool `protobuf:"varint,1,opt,name=Result,proto3" json:"Result,omitempty"` + Message string `protobuf:"bytes,2,opt,name=Message,proto3" json:"Message,omitempty"` + Port string `protobuf:"bytes,3,opt,name=Port,proto3" json:"Port,omitempty"` + ServerUrl string `protobuf:"bytes,4,opt,name=ServerUrl,proto3" json:"ServerUrl,omitempty"` +} + +func (x *GetRunningServerResponse) Reset() { + *x = GetRunningServerResponse{} + if protoimpl.UnsafeEnabled { + mi := &file_JupyterServerHostService_v1_proto_msgTypes[1] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) + } +} + +func (x *GetRunningServerResponse) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*GetRunningServerResponse) ProtoMessage() {} + +func (x *GetRunningServerResponse) ProtoReflect() protoreflect.Message { + mi := &file_JupyterServerHostService_v1_proto_msgTypes[1] + if protoimpl.UnsafeEnabled && x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use GetRunningServerResponse.ProtoReflect.Descriptor instead. +func (*GetRunningServerResponse) Descriptor() ([]byte, []int) { + return file_JupyterServerHostService_v1_proto_rawDescGZIP(), []int{1} +} + +func (x *GetRunningServerResponse) GetResult() bool { + if x != nil { + return x.Result + } + return false +} + +func (x *GetRunningServerResponse) GetMessage() string { + if x != nil { + return x.Message + } + return "" +} + +func (x *GetRunningServerResponse) GetPort() string { + if x != nil { + return x.Port + } + return "" +} + +func (x *GetRunningServerResponse) GetServerUrl() string { + if x != nil { + return x.ServerUrl + } + return "" +} + +var File_JupyterServerHostService_v1_proto protoreflect.FileDescriptor + +var file_JupyterServerHostService_v1_proto_rawDesc = []byte{ + 0x0a, 0x21, 0x4a, 0x75, 0x70, 0x79, 0x74, 0x65, 0x72, 0x53, 0x65, 0x72, 0x76, 0x65, 0x72, 0x48, + 0x6f, 0x73, 0x74, 0x53, 0x65, 0x72, 0x76, 0x69, 0x63, 0x65, 0x2e, 0x76, 0x31, 0x2e, 0x70, 0x72, + 0x6f, 0x74, 0x6f, 0x12, 0x2b, 0x43, 0x6f, 0x64, 0x65, 0x73, 0x70, 0x61, 0x63, 0x65, 0x73, 0x2e, + 0x47, 0x72, 0x70, 0x63, 0x2e, 0x4a, 0x75, 0x70, 0x79, 0x74, 0x65, 0x72, 0x53, 0x65, 0x72, 0x76, + 0x65, 0x72, 0x48, 0x6f, 0x73, 0x74, 0x53, 0x65, 0x72, 0x76, 0x69, 0x63, 0x65, 0x2e, 0x76, 0x31, + 0x22, 0x19, 0x0a, 0x17, 0x47, 0x65, 0x74, 0x52, 0x75, 0x6e, 0x6e, 0x69, 0x6e, 0x67, 0x53, 0x65, + 0x72, 0x76, 0x65, 0x72, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x22, 0x7e, 0x0a, 0x18, 0x47, + 0x65, 0x74, 0x52, 0x75, 0x6e, 0x6e, 0x69, 0x6e, 0x67, 0x53, 0x65, 0x72, 0x76, 0x65, 0x72, 0x52, + 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x12, 0x16, 0x0a, 0x06, 0x52, 0x65, 0x73, 0x75, 0x6c, + 0x74, 0x18, 0x01, 0x20, 0x01, 0x28, 0x08, 0x52, 0x06, 0x52, 0x65, 0x73, 0x75, 0x6c, 0x74, 0x12, + 0x18, 0x0a, 0x07, 0x4d, 0x65, 0x73, 0x73, 0x61, 0x67, 0x65, 0x18, 0x02, 0x20, 0x01, 0x28, 0x09, + 0x52, 0x07, 0x4d, 0x65, 0x73, 0x73, 0x61, 0x67, 0x65, 0x12, 0x12, 0x0a, 0x04, 0x50, 0x6f, 0x72, + 0x74, 0x18, 0x03, 0x20, 0x01, 0x28, 0x09, 0x52, 0x04, 0x50, 0x6f, 0x72, 0x74, 0x12, 0x1c, 0x0a, + 0x09, 0x53, 0x65, 0x72, 0x76, 0x65, 0x72, 0x55, 0x72, 0x6c, 0x18, 0x04, 0x20, 0x01, 0x28, 0x09, + 0x52, 0x09, 0x53, 0x65, 0x72, 0x76, 0x65, 0x72, 0x55, 0x72, 0x6c, 0x32, 0xb5, 0x01, 0x0a, 0x11, + 0x4a, 0x75, 0x70, 0x79, 0x74, 0x65, 0x72, 0x53, 0x65, 0x72, 0x76, 0x65, 0x72, 0x48, 0x6f, 0x73, + 0x74, 0x12, 0x9f, 0x01, 0x0a, 0x10, 0x47, 0x65, 0x74, 0x52, 0x75, 0x6e, 0x6e, 0x69, 0x6e, 0x67, + 0x53, 0x65, 0x72, 0x76, 0x65, 0x72, 0x12, 0x44, 0x2e, 0x43, 0x6f, 0x64, 0x65, 0x73, 0x70, 0x61, + 0x63, 0x65, 0x73, 0x2e, 0x47, 0x72, 0x70, 0x63, 0x2e, 0x4a, 0x75, 0x70, 0x79, 0x74, 0x65, 0x72, + 0x53, 0x65, 0x72, 0x76, 0x65, 0x72, 0x48, 0x6f, 0x73, 0x74, 0x53, 0x65, 0x72, 0x76, 0x69, 0x63, + 0x65, 0x2e, 0x76, 0x31, 0x2e, 0x47, 0x65, 0x74, 0x52, 0x75, 0x6e, 0x6e, 0x69, 0x6e, 0x67, 0x53, + 0x65, 0x72, 0x76, 0x65, 0x72, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x1a, 0x45, 0x2e, 0x43, + 0x6f, 0x64, 0x65, 0x73, 0x70, 0x61, 0x63, 0x65, 0x73, 0x2e, 0x47, 0x72, 0x70, 0x63, 0x2e, 0x4a, + 0x75, 0x70, 0x79, 0x74, 0x65, 0x72, 0x53, 0x65, 0x72, 0x76, 0x65, 0x72, 0x48, 0x6f, 0x73, 0x74, + 0x53, 0x65, 0x72, 0x76, 0x69, 0x63, 0x65, 0x2e, 0x76, 0x31, 0x2e, 0x47, 0x65, 0x74, 0x52, 0x75, + 0x6e, 0x6e, 0x69, 0x6e, 0x67, 0x53, 0x65, 0x72, 0x76, 0x65, 0x72, 0x52, 0x65, 0x73, 0x70, 0x6f, + 0x6e, 0x73, 0x65, 0x42, 0x0b, 0x5a, 0x09, 0x2e, 0x2f, 0x6a, 0x75, 0x70, 0x79, 0x74, 0x65, 0x72, + 0x62, 0x06, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x33, +} + +var ( + file_JupyterServerHostService_v1_proto_rawDescOnce sync.Once + file_JupyterServerHostService_v1_proto_rawDescData = file_JupyterServerHostService_v1_proto_rawDesc +) + +func file_JupyterServerHostService_v1_proto_rawDescGZIP() []byte { + file_JupyterServerHostService_v1_proto_rawDescOnce.Do(func() { + file_JupyterServerHostService_v1_proto_rawDescData = protoimpl.X.CompressGZIP(file_JupyterServerHostService_v1_proto_rawDescData) + }) + return file_JupyterServerHostService_v1_proto_rawDescData +} + +var file_JupyterServerHostService_v1_proto_msgTypes = make([]protoimpl.MessageInfo, 2) +var file_JupyterServerHostService_v1_proto_goTypes = []interface{}{ + (*GetRunningServerRequest)(nil), // 0: Codespaces.Grpc.JupyterServerHostService.v1.GetRunningServerRequest + (*GetRunningServerResponse)(nil), // 1: Codespaces.Grpc.JupyterServerHostService.v1.GetRunningServerResponse +} +var file_JupyterServerHostService_v1_proto_depIdxs = []int32{ + 0, // 0: Codespaces.Grpc.JupyterServerHostService.v1.JupyterServerHost.GetRunningServer:input_type -> Codespaces.Grpc.JupyterServerHostService.v1.GetRunningServerRequest + 1, // 1: Codespaces.Grpc.JupyterServerHostService.v1.JupyterServerHost.GetRunningServer:output_type -> Codespaces.Grpc.JupyterServerHostService.v1.GetRunningServerResponse + 1, // [1:2] is the sub-list for method output_type + 0, // [0:1] is the sub-list for method input_type + 0, // [0:0] is the sub-list for extension type_name + 0, // [0:0] is the sub-list for extension extendee + 0, // [0:0] is the sub-list for field type_name +} + +func init() { file_JupyterServerHostService_v1_proto_init() } +func file_JupyterServerHostService_v1_proto_init() { + if File_JupyterServerHostService_v1_proto != nil { + return + } + if !protoimpl.UnsafeEnabled { + file_JupyterServerHostService_v1_proto_msgTypes[0].Exporter = func(v interface{}, i int) interface{} { + switch v := v.(*GetRunningServerRequest); i { + case 0: + return &v.state + case 1: + return &v.sizeCache + case 2: + return &v.unknownFields + default: + return nil + } + } + file_JupyterServerHostService_v1_proto_msgTypes[1].Exporter = func(v interface{}, i int) interface{} { + switch v := v.(*GetRunningServerResponse); i { + case 0: + return &v.state + case 1: + return &v.sizeCache + case 2: + return &v.unknownFields + default: + return nil + } + } + } + type x struct{} + out := protoimpl.TypeBuilder{ + File: protoimpl.DescBuilder{ + GoPackagePath: reflect.TypeOf(x{}).PkgPath(), + RawDescriptor: file_JupyterServerHostService_v1_proto_rawDesc, + NumEnums: 0, + NumMessages: 2, + NumExtensions: 0, + NumServices: 1, + }, + GoTypes: file_JupyterServerHostService_v1_proto_goTypes, + DependencyIndexes: file_JupyterServerHostService_v1_proto_depIdxs, + MessageInfos: file_JupyterServerHostService_v1_proto_msgTypes, + }.Build() + File_JupyterServerHostService_v1_proto = out.File + file_JupyterServerHostService_v1_proto_rawDesc = nil + file_JupyterServerHostService_v1_proto_goTypes = nil + file_JupyterServerHostService_v1_proto_depIdxs = nil +} diff --git a/pkg/grpc/jupyter/JupyterServerHostService.v1.proto b/pkg/grpc/jupyter/JupyterServerHostService.v1.proto new file mode 100644 index 000000000..337e7cf41 --- /dev/null +++ b/pkg/grpc/jupyter/JupyterServerHostService.v1.proto @@ -0,0 +1,19 @@ +syntax = "proto3"; + +option go_package = "./jupyter"; + +package Codespaces.Grpc.JupyterServerHostService.v1; + +service JupyterServerHost { + rpc GetRunningServer (GetRunningServerRequest) returns (GetRunningServerResponse); +} + +message GetRunningServerRequest { +} + +message GetRunningServerResponse { + bool Result = 1; + string Message = 2; + string Port = 3; + string ServerUrl = 4; +} diff --git a/pkg/grpc/jupyter/JupyterServerHostService.v1_grpc.pb.go b/pkg/grpc/jupyter/JupyterServerHostService.v1_grpc.pb.go new file mode 100644 index 000000000..8ba53014d --- /dev/null +++ b/pkg/grpc/jupyter/JupyterServerHostService.v1_grpc.pb.go @@ -0,0 +1,105 @@ +// Code generated by protoc-gen-go-grpc. DO NOT EDIT. +// versions: +// - protoc-gen-go-grpc v1.2.0 +// - protoc v3.21.3 +// source: JupyterServerHostService.v1.proto + +package jupyter + +import ( + context "context" + grpc "google.golang.org/grpc" + codes "google.golang.org/grpc/codes" + status "google.golang.org/grpc/status" +) + +// This is a compile-time assertion to ensure that this generated file +// is compatible with the grpc package it is being compiled against. +// Requires gRPC-Go v1.32.0 or later. +const _ = grpc.SupportPackageIsVersion7 + +// JupyterServerHostClient is the client API for JupyterServerHost service. +// +// For semantics around ctx use and closing/ending streaming RPCs, please refer to https://pkg.go.dev/google.golang.org/grpc/?tab=doc#ClientConn.NewStream. +type JupyterServerHostClient interface { + GetRunningServer(ctx context.Context, in *GetRunningServerRequest, opts ...grpc.CallOption) (*GetRunningServerResponse, error) +} + +type jupyterServerHostClient struct { + cc grpc.ClientConnInterface +} + +func NewJupyterServerHostClient(cc grpc.ClientConnInterface) JupyterServerHostClient { + return &jupyterServerHostClient{cc} +} + +func (c *jupyterServerHostClient) GetRunningServer(ctx context.Context, in *GetRunningServerRequest, opts ...grpc.CallOption) (*GetRunningServerResponse, error) { + out := new(GetRunningServerResponse) + err := c.cc.Invoke(ctx, "/Codespaces.Grpc.JupyterServerHostService.v1.JupyterServerHost/GetRunningServer", in, out, opts...) + if err != nil { + return nil, err + } + return out, nil +} + +// JupyterServerHostServer is the server API for JupyterServerHost service. +// All implementations must embed UnimplementedJupyterServerHostServer +// for forward compatibility +type JupyterServerHostServer interface { + GetRunningServer(context.Context, *GetRunningServerRequest) (*GetRunningServerResponse, error) + mustEmbedUnimplementedJupyterServerHostServer() +} + +// UnimplementedJupyterServerHostServer must be embedded to have forward compatible implementations. +type UnimplementedJupyterServerHostServer struct { +} + +func (UnimplementedJupyterServerHostServer) GetRunningServer(context.Context, *GetRunningServerRequest) (*GetRunningServerResponse, error) { + return nil, status.Errorf(codes.Unimplemented, "method GetRunningServer not implemented") +} +func (UnimplementedJupyterServerHostServer) mustEmbedUnimplementedJupyterServerHostServer() {} + +// UnsafeJupyterServerHostServer may be embedded to opt out of forward compatibility for this service. +// Use of this interface is not recommended, as added methods to JupyterServerHostServer will +// result in compilation errors. +type UnsafeJupyterServerHostServer interface { + mustEmbedUnimplementedJupyterServerHostServer() +} + +func RegisterJupyterServerHostServer(s grpc.ServiceRegistrar, srv JupyterServerHostServer) { + s.RegisterService(&JupyterServerHost_ServiceDesc, srv) +} + +func _JupyterServerHost_GetRunningServer_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) { + in := new(GetRunningServerRequest) + if err := dec(in); err != nil { + return nil, err + } + if interceptor == nil { + return srv.(JupyterServerHostServer).GetRunningServer(ctx, in) + } + info := &grpc.UnaryServerInfo{ + Server: srv, + FullMethod: "/Codespaces.Grpc.JupyterServerHostService.v1.JupyterServerHost/GetRunningServer", + } + handler := func(ctx context.Context, req interface{}) (interface{}, error) { + return srv.(JupyterServerHostServer).GetRunningServer(ctx, req.(*GetRunningServerRequest)) + } + return interceptor(ctx, in, info, handler) +} + +// JupyterServerHost_ServiceDesc is the grpc.ServiceDesc for JupyterServerHost service. +// It's only intended for direct use with grpc.RegisterService, +// and not to be introspected or modified (even as a copy) +var JupyterServerHost_ServiceDesc = grpc.ServiceDesc{ + ServiceName: "Codespaces.Grpc.JupyterServerHostService.v1.JupyterServerHost", + HandlerType: (*JupyterServerHostServer)(nil), + Methods: []grpc.MethodDesc{ + { + MethodName: "GetRunningServer", + Handler: _JupyterServerHost_GetRunningServer_Handler, + }, + }, + Streams: []grpc.StreamDesc{}, + Metadata: "JupyterServerHostService.v1.proto", +} diff --git a/pkg/liveshare/client.go b/pkg/liveshare/client.go index 570e8615c..2fff9c766 100644 --- a/pkg/liveshare/client.go +++ b/pkg/liveshare/client.go @@ -15,13 +15,20 @@ import ( "crypto/tls" "errors" "fmt" + "net" "net/url" "strings" "time" + "github.com/cli/cli/v2/pkg/grpc" "github.com/opentracing/opentracing-go" ) +const ( + codespacesInternalPort = 16634 + codespacesInternalSessionName = "CodespacesInternal" +) + type logger interface { Println(v ...interface{}) Printf(f string, v ...interface{}) @@ -112,15 +119,49 @@ func Connect(ctx context.Context, opts Options) (*Session, error) { s := &Session{ ssh: ssh, rpc: rpc, + grpc: grpc.New(), clientName: opts.ClientName, keepAliveReason: make(chan string, 1), logger: opts.Logger, } go s.heartbeat(ctx, 1*time.Minute) + // Connect to the gRPC server so we can make requests anywhere we have access to the session + s.connectToGrpcServer(ctx, opts.SessionToken) + return s, nil } +// Connects to the gRPC server running on the host VM +func (s *Session) connectToGrpcServer(ctx context.Context, token string) error { + listen, err := net.Listen("tcp", fmt.Sprintf("127.0.0.1:%d", 0)) + if err != nil { + return err + } + + // Tunnel the remote gRPC server port to the local port + localGrpcServerPort := listen.Addr().(*net.TCPAddr).Port + internalTunnelClosed := make(chan error, 1) + go func() { + fwd := NewPortForwarder(s, codespacesInternalSessionName, codespacesInternalPort, true) + internalTunnelClosed <- fwd.ForwardToListener(ctx, listen) + }() + + // Make a connection to the gRPC server + err = s.grpc.Connect(ctx, localGrpcServerPort, token) + + if err != nil { + return err + } + + select { + case err := <-internalTunnelClosed: + return fmt.Errorf("internal tunnel closed: %w", err) + default: + return nil // success + } +} + type clientCapabilities struct { IsNonInteractive bool `json:"isNonInteractive"` } diff --git a/pkg/liveshare/session.go b/pkg/liveshare/session.go index f912fa5ea..b1c5ab7ca 100644 --- a/pkg/liveshare/session.go +++ b/pkg/liveshare/session.go @@ -8,6 +8,7 @@ import ( "strings" "time" + "github.com/cli/cli/v2/pkg/grpc" "github.com/opentracing/opentracing-go" "golang.org/x/crypto/ssh" "golang.org/x/sync/errgroup" @@ -21,8 +22,9 @@ type ChannelID struct { // A Session represents the session between a connected Live Share client and server. type Session struct { - ssh *sshSession - rpc *rpcClient + ssh *sshSession + rpc *rpcClient + grpc *grpc.GrpcClient clientName string keepAliveReason chan string @@ -100,27 +102,7 @@ func (s *Session) StartSSHServerWithOptions(ctx context.Context, options StartSS // StartJupyterServer starts a Juypyter server in the container and returns // the port on which it listens and the server URL. func (s *Session) StartJupyterServer(ctx context.Context) (int, string, error) { - var response struct { - Result bool `json:"result"` - Message string `json:"message"` - Port string `json:"port"` - ServerUrl string `json:"serverUrl"` - } - - if err := s.rpc.do(ctx, "IJupyterServerHostService.getRunningServer", []string{}, &response); err != nil { - return 0, "", fmt.Errorf("failed to invoke JupyterLab RPC: %w", err) - } - - if !response.Result { - return 0, "", fmt.Errorf("failed to start JupyterLab: %s", response.Message) - } - - port, err := strconv.Atoi(response.Port) - if err != nil { - return 0, "", fmt.Errorf("failed to parse JupyterLab port: %w", err) - } - - return port, response.ServerUrl, nil + return s.grpc.GetRunningServer() } // heartbeat runs until context cancellation, periodically checking whether there is a From 93f033fe8763a1660ff696061cd66bbfe4bf092f Mon Sep 17 00:00:00 2001 From: David Gardiner Date: Tue, 27 Sep 2022 16:01:10 -0700 Subject: [PATCH 02/44] Address comments --- {pkg => internal/codespaces}/grpc/client.go | 40 ++++++++++++------- .../jupyter/JupyterServerHostService.v1.pb.go | 0 .../jupyter/JupyterServerHostService.v1.proto | 0 .../JupyterServerHostService.v1_grpc.pb.go | 0 pkg/liveshare/client.go | 15 ++++--- pkg/liveshare/session.go | 11 +++-- 6 files changed, 43 insertions(+), 23 deletions(-) rename {pkg => internal/codespaces}/grpc/client.go (56%) rename {pkg => internal/codespaces}/grpc/jupyter/JupyterServerHostService.v1.pb.go (100%) rename {pkg => internal/codespaces}/grpc/jupyter/JupyterServerHostService.v1.proto (100%) rename {pkg => internal/codespaces}/grpc/jupyter/JupyterServerHostService.v1_grpc.pb.go (100%) diff --git a/pkg/grpc/client.go b/internal/codespaces/grpc/client.go similarity index 56% rename from pkg/grpc/client.go rename to internal/codespaces/grpc/client.go index ddc50bb13..4ae5b35ed 100644 --- a/pkg/grpc/client.go +++ b/internal/codespaces/grpc/client.go @@ -6,58 +6,71 @@ package grpc import ( "context" "fmt" + "net" "strconv" "time" - "github.com/cli/cli/v2/pkg/grpc/jupyter" + "github.com/cli/cli/v2/internal/codespaces/grpc/jupyter" "google.golang.org/grpc" "google.golang.org/grpc/credentials/insecure" "google.golang.org/grpc/metadata" ) const ( - requestTimeout = 30 * time.Second + connectionTimeout = 5 * time.Second + requestTimeout = 30 * time.Second ) -type GrpcClient struct { +type Client struct { conn *grpc.ClientConn token string + listener net.Listener jupyterClient jupyter.JupyterServerHostClient } -func New() *GrpcClient { - return &GrpcClient{} +func NewClient() *Client { + return &Client{} } // Connects to the gRPC server on the given port -func (g *GrpcClient) Connect(ctx context.Context, port int, token string) error { +func (g *Client) Connect(ctx context.Context, listener net.Listener, port int, token string) error { // Attempt to connect to the given port - conn, err := grpc.Dial(fmt.Sprintf("localhost:%d", port), grpc.WithTransportCredentials(insecure.NewCredentials()), grpc.WithBlock()) - + conn, err := grpc.Dial(fmt.Sprintf("127.0.0.1:%d", port), grpc.WithTimeout(connectionTimeout), grpc.WithTransportCredentials(insecure.NewCredentials()), grpc.WithBlock()) if err != nil { - return fmt.Errorf("Failed to connect to the internal server on port %d", port) + return err } g.conn = conn g.token = token + g.listener = listener g.jupyterClient = jupyter.NewJupyterServerHostClient(conn) return nil } +// Closes the gRPC connection +func (g *Client) Close() error { + // Closing the local listener effectively closes the gRPC connection + if err := g.listener.Close(); err != nil { + g.conn.Close() // If we fail to close the listener, explicitly close the gRPC connection and ignore any error + return fmt.Errorf("failed to close local tcp port listener: %w", err) + } + + return nil +} + // Appends the authentication token to the gRPC context -func (g *GrpcClient) appendMetadata(ctx context.Context) context.Context { +func (g *Client) appendMetadata(ctx context.Context) context.Context { return metadata.AppendToOutgoingContext(ctx, "Authorization", "Bearer "+g.token) } // Starts a remote JupyterLab server to allow the user to connect to the codespace via JupyterLab in their browser -func (g *GrpcClient) GetRunningServer() (int, string, error) { +func (g *Client) StartJupyterServer() (port int, serverUrl string, err error) { ctx, cancel := context.WithTimeout(context.Background(), requestTimeout) ctx = g.appendMetadata(ctx) defer cancel() response, err := g.jupyterClient.GetRunningServer(ctx, &jupyter.GetRunningServerRequest{}) - if err != nil { return 0, "", fmt.Errorf("failed to invoke JupyterLab RPC: %w", err) } @@ -66,8 +79,7 @@ func (g *GrpcClient) GetRunningServer() (int, string, error) { return 0, "", fmt.Errorf("failed to start JupyterLab: %s", response.Message) } - port, err := strconv.Atoi(response.Port) - + port, err = strconv.Atoi(response.Port) if err != nil { return 0, "", fmt.Errorf("failed to parse JupyterLab port: %w", err) } diff --git a/pkg/grpc/jupyter/JupyterServerHostService.v1.pb.go b/internal/codespaces/grpc/jupyter/JupyterServerHostService.v1.pb.go similarity index 100% rename from pkg/grpc/jupyter/JupyterServerHostService.v1.pb.go rename to internal/codespaces/grpc/jupyter/JupyterServerHostService.v1.pb.go diff --git a/pkg/grpc/jupyter/JupyterServerHostService.v1.proto b/internal/codespaces/grpc/jupyter/JupyterServerHostService.v1.proto similarity index 100% rename from pkg/grpc/jupyter/JupyterServerHostService.v1.proto rename to internal/codespaces/grpc/jupyter/JupyterServerHostService.v1.proto diff --git a/pkg/grpc/jupyter/JupyterServerHostService.v1_grpc.pb.go b/internal/codespaces/grpc/jupyter/JupyterServerHostService.v1_grpc.pb.go similarity index 100% rename from pkg/grpc/jupyter/JupyterServerHostService.v1_grpc.pb.go rename to internal/codespaces/grpc/jupyter/JupyterServerHostService.v1_grpc.pb.go diff --git a/pkg/liveshare/client.go b/pkg/liveshare/client.go index 2fff9c766..5be3b1870 100644 --- a/pkg/liveshare/client.go +++ b/pkg/liveshare/client.go @@ -20,7 +20,7 @@ import ( "strings" "time" - "github.com/cli/cli/v2/pkg/grpc" + "github.com/cli/cli/v2/internal/codespaces/grpc" "github.com/opentracing/opentracing-go" ) @@ -119,7 +119,7 @@ func Connect(ctx context.Context, opts Options) (*Session, error) { s := &Session{ ssh: ssh, rpc: rpc, - grpc: grpc.New(), + grpc: grpc.NewClient(), clientName: opts.ClientName, keepAliveReason: make(chan string, 1), logger: opts.Logger, @@ -127,7 +127,10 @@ func Connect(ctx context.Context, opts Options) (*Session, error) { go s.heartbeat(ctx, 1*time.Minute) // Connect to the gRPC server so we can make requests anywhere we have access to the session - s.connectToGrpcServer(ctx, opts.SessionToken) + err = s.connectToGrpcServer(ctx, opts.SessionToken) + if err != nil { + return nil, fmt.Errorf("error connecting to internal server: %w", err) + } return s, nil } @@ -136,7 +139,7 @@ func Connect(ctx context.Context, opts Options) (*Session, error) { func (s *Session) connectToGrpcServer(ctx context.Context, token string) error { listen, err := net.Listen("tcp", fmt.Sprintf("127.0.0.1:%d", 0)) if err != nil { - return err + return fmt.Errorf("failed to listen to local port over tcp: %w", err) } // Tunnel the remote gRPC server port to the local port @@ -148,10 +151,10 @@ func (s *Session) connectToGrpcServer(ctx context.Context, token string) error { }() // Make a connection to the gRPC server - err = s.grpc.Connect(ctx, localGrpcServerPort, token) + err = s.grpc.Connect(ctx, listen, localGrpcServerPort, token) if err != nil { - return err + return fmt.Errorf("failed to establish connection on port %d: %w", localGrpcServerPort, err) } select { diff --git a/pkg/liveshare/session.go b/pkg/liveshare/session.go index b1c5ab7ca..8207c8ba9 100644 --- a/pkg/liveshare/session.go +++ b/pkg/liveshare/session.go @@ -8,7 +8,7 @@ import ( "strings" "time" - "github.com/cli/cli/v2/pkg/grpc" + "github.com/cli/cli/v2/internal/codespaces/grpc" "github.com/opentracing/opentracing-go" "golang.org/x/crypto/ssh" "golang.org/x/sync/errgroup" @@ -24,7 +24,7 @@ type ChannelID struct { type Session struct { ssh *sshSession rpc *rpcClient - grpc *grpc.GrpcClient + grpc *grpc.Client clientName string keepAliveReason chan string @@ -45,6 +45,11 @@ func (s *Session) Close() error { return fmt.Errorf("error while closing Live Share session: %w", err) } + // Close the connection to the gRPC server + if err := s.grpc.Close(); err != nil { + return fmt.Errorf("error while closing internal server connection: %w", err) + } + return nil } @@ -102,7 +107,7 @@ func (s *Session) StartSSHServerWithOptions(ctx context.Context, options StartSS // StartJupyterServer starts a Juypyter server in the container and returns // the port on which it listens and the server URL. func (s *Session) StartJupyterServer(ctx context.Context) (int, string, error) { - return s.grpc.GetRunningServer() + return s.grpc.StartJupyterServer() } // heartbeat runs until context cancellation, periodically checking whether there is a From 3f34d6d95e6e5e5a914dac13dcf0829a9fd2fce1 Mon Sep 17 00:00:00 2001 From: David Gardiner Date: Wed, 28 Sep 2022 10:39:26 -0700 Subject: [PATCH 03/44] Make suggested gRPC client changes --- internal/codespaces/grpc/client.go | 46 +++++++++++++++++++++++------- pkg/cmd/codespace/jupyter.go | 9 +++++- pkg/liveshare/client.go | 44 ---------------------------- pkg/liveshare/session.go | 33 ++++++++++++++------- 4 files changed, 66 insertions(+), 66 deletions(-) diff --git a/internal/codespaces/grpc/client.go b/internal/codespaces/grpc/client.go index 4ae5b35ed..860893f68 100644 --- a/internal/codespaces/grpc/client.go +++ b/internal/codespaces/grpc/client.go @@ -11,6 +11,8 @@ import ( "time" "github.com/cli/cli/v2/internal/codespaces/grpc/jupyter" + "github.com/cli/cli/v2/pkg/liveshare" + "golang.org/x/crypto/ssh" "google.golang.org/grpc" "google.golang.org/grpc/credentials/insecure" "google.golang.org/grpc/metadata" @@ -21,6 +23,11 @@ const ( requestTimeout = 30 * time.Second ) +const ( + codespacesInternalPort = 16634 + codespacesInternalSessionName = "CodespacesInternal" +) + type Client struct { conn *grpc.ClientConn token string @@ -28,24 +35,41 @@ type Client struct { jupyterClient jupyter.JupyterServerHostClient } -func NewClient() *Client { - return &Client{} +type liveshareSession interface { + KeepAlive(string) + OpenStreamingChannel(context.Context, liveshare.ChannelID) (ssh.Channel, error) + StartSharing(context.Context, string, int) (liveshare.ChannelID, error) } // Connects to the gRPC server on the given port -func (g *Client) Connect(ctx context.Context, listener net.Listener, port int, token string) error { - // Attempt to connect to the given port - conn, err := grpc.Dial(fmt.Sprintf("127.0.0.1:%d", port), grpc.WithTimeout(connectionTimeout), grpc.WithTransportCredentials(insecure.NewCredentials()), grpc.WithBlock()) +func Connect(ctx context.Context, session liveshareSession, token string) (*Client, error) { + listener, err := net.Listen("tcp", fmt.Sprintf("127.0.0.1:%d", 0)) if err != nil { - return err + return nil, fmt.Errorf("failed to listen to local port over tcp: %w", err) } - g.conn = conn - g.token = token - g.listener = listener - g.jupyterClient = jupyter.NewJupyterServerHostClient(conn) + // Tunnel the remote gRPC server port to the local port + localGrpcServerPort := listener.Addr().(*net.TCPAddr).Port + internalTunnelClosed := make(chan error, 1) + go func() { + fwd := liveshare.NewPortForwarder(session, codespacesInternalSessionName, codespacesInternalPort, true) + internalTunnelClosed <- fwd.ForwardToListener(ctx, listener) + }() - return nil + // Attempt to connect to the given port + conn, err := grpc.Dial(fmt.Sprintf("127.0.0.1:%d", localGrpcServerPort), grpc.WithTimeout(connectionTimeout), grpc.WithTransportCredentials(insecure.NewCredentials()), grpc.WithBlock()) + if err != nil { + return nil, err + } + + g := &Client{ + conn: conn, + token: token, + listener: listener, + jupyterClient: jupyter.NewJupyterServerHostClient(conn), + } + + return g, nil } // Closes the gRPC connection diff --git a/pkg/cmd/codespace/jupyter.go b/pkg/cmd/codespace/jupyter.go index 77bedb301..bdeb2f4f4 100644 --- a/pkg/cmd/codespace/jupyter.go +++ b/pkg/cmd/codespace/jupyter.go @@ -6,6 +6,7 @@ import ( "net" "strings" + "github.com/cli/cli/v2/internal/codespaces/grpc" "github.com/cli/cli/v2/pkg/liveshare" "github.com/spf13/cobra" ) @@ -43,8 +44,14 @@ func (a *App) Jupyter(ctx context.Context, codespaceName string) (err error) { } defer safeClose(session, &err) + client, err := grpc.Connect(ctx, session, "") + if err != nil { + return fmt.Errorf("error connecting to internal server: %w", err) + } + defer safeClose(client, &err) + a.StartProgressIndicatorWithLabel("Starting JupyterLab on codespace") - serverPort, serverUrl, err := session.StartJupyterServer(ctx) + serverPort, serverUrl, err := client.StartJupyterServer() a.StopProgressIndicator() if err != nil { return fmt.Errorf("failed to start JupyterLab server: %w", err) diff --git a/pkg/liveshare/client.go b/pkg/liveshare/client.go index 5be3b1870..570e8615c 100644 --- a/pkg/liveshare/client.go +++ b/pkg/liveshare/client.go @@ -15,20 +15,13 @@ import ( "crypto/tls" "errors" "fmt" - "net" "net/url" "strings" "time" - "github.com/cli/cli/v2/internal/codespaces/grpc" "github.com/opentracing/opentracing-go" ) -const ( - codespacesInternalPort = 16634 - codespacesInternalSessionName = "CodespacesInternal" -) - type logger interface { Println(v ...interface{}) Printf(f string, v ...interface{}) @@ -119,52 +112,15 @@ func Connect(ctx context.Context, opts Options) (*Session, error) { s := &Session{ ssh: ssh, rpc: rpc, - grpc: grpc.NewClient(), clientName: opts.ClientName, keepAliveReason: make(chan string, 1), logger: opts.Logger, } go s.heartbeat(ctx, 1*time.Minute) - // Connect to the gRPC server so we can make requests anywhere we have access to the session - err = s.connectToGrpcServer(ctx, opts.SessionToken) - if err != nil { - return nil, fmt.Errorf("error connecting to internal server: %w", err) - } - return s, nil } -// Connects to the gRPC server running on the host VM -func (s *Session) connectToGrpcServer(ctx context.Context, token string) error { - listen, err := net.Listen("tcp", fmt.Sprintf("127.0.0.1:%d", 0)) - if err != nil { - return fmt.Errorf("failed to listen to local port over tcp: %w", err) - } - - // Tunnel the remote gRPC server port to the local port - localGrpcServerPort := listen.Addr().(*net.TCPAddr).Port - internalTunnelClosed := make(chan error, 1) - go func() { - fwd := NewPortForwarder(s, codespacesInternalSessionName, codespacesInternalPort, true) - internalTunnelClosed <- fwd.ForwardToListener(ctx, listen) - }() - - // Make a connection to the gRPC server - err = s.grpc.Connect(ctx, listen, localGrpcServerPort, token) - - if err != nil { - return fmt.Errorf("failed to establish connection on port %d: %w", localGrpcServerPort, err) - } - - select { - case err := <-internalTunnelClosed: - return fmt.Errorf("internal tunnel closed: %w", err) - default: - return nil // success - } -} - type clientCapabilities struct { IsNonInteractive bool `json:"isNonInteractive"` } diff --git a/pkg/liveshare/session.go b/pkg/liveshare/session.go index 8207c8ba9..f912fa5ea 100644 --- a/pkg/liveshare/session.go +++ b/pkg/liveshare/session.go @@ -8,7 +8,6 @@ import ( "strings" "time" - "github.com/cli/cli/v2/internal/codespaces/grpc" "github.com/opentracing/opentracing-go" "golang.org/x/crypto/ssh" "golang.org/x/sync/errgroup" @@ -22,9 +21,8 @@ type ChannelID struct { // A Session represents the session between a connected Live Share client and server. type Session struct { - ssh *sshSession - rpc *rpcClient - grpc *grpc.Client + ssh *sshSession + rpc *rpcClient clientName string keepAliveReason chan string @@ -45,11 +43,6 @@ func (s *Session) Close() error { return fmt.Errorf("error while closing Live Share session: %w", err) } - // Close the connection to the gRPC server - if err := s.grpc.Close(); err != nil { - return fmt.Errorf("error while closing internal server connection: %w", err) - } - return nil } @@ -107,7 +100,27 @@ func (s *Session) StartSSHServerWithOptions(ctx context.Context, options StartSS // StartJupyterServer starts a Juypyter server in the container and returns // the port on which it listens and the server URL. func (s *Session) StartJupyterServer(ctx context.Context) (int, string, error) { - return s.grpc.StartJupyterServer() + var response struct { + Result bool `json:"result"` + Message string `json:"message"` + Port string `json:"port"` + ServerUrl string `json:"serverUrl"` + } + + if err := s.rpc.do(ctx, "IJupyterServerHostService.getRunningServer", []string{}, &response); err != nil { + return 0, "", fmt.Errorf("failed to invoke JupyterLab RPC: %w", err) + } + + if !response.Result { + return 0, "", fmt.Errorf("failed to start JupyterLab: %s", response.Message) + } + + port, err := strconv.Atoi(response.Port) + if err != nil { + return 0, "", fmt.Errorf("failed to parse JupyterLab port: %w", err) + } + + return port, response.ServerUrl, nil } // heartbeat runs until context cancellation, periodically checking whether there is a From 35d2cf30d0b0cc12119497cc0136f0ee8340a6ad Mon Sep 17 00:00:00 2001 From: David Gardiner Date: Wed, 28 Sep 2022 11:28:35 -0700 Subject: [PATCH 04/44] Send session token --- pkg/cmd/codespace/jupyter.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pkg/cmd/codespace/jupyter.go b/pkg/cmd/codespace/jupyter.go index bdeb2f4f4..b5ce34a9f 100644 --- a/pkg/cmd/codespace/jupyter.go +++ b/pkg/cmd/codespace/jupyter.go @@ -44,7 +44,7 @@ func (a *App) Jupyter(ctx context.Context, codespaceName string) (err error) { } defer safeClose(session, &err) - client, err := grpc.Connect(ctx, session, "") + client, err := grpc.Connect(ctx, session, codespace.Connection.SessionToken) if err != nil { return fmt.Errorf("error connecting to internal server: %w", err) } From 766e6a2314b176a8000f23284b7368fbcdc573c3 Mon Sep 17 00:00:00 2001 From: David Gardiner Date: Thu, 29 Sep 2022 09:58:44 -0700 Subject: [PATCH 05/44] Use existing context --- internal/codespaces/grpc/client.go | 4 ++-- pkg/cmd/codespace/jupyter.go | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/internal/codespaces/grpc/client.go b/internal/codespaces/grpc/client.go index 860893f68..5bfa179ba 100644 --- a/internal/codespaces/grpc/client.go +++ b/internal/codespaces/grpc/client.go @@ -89,8 +89,8 @@ func (g *Client) appendMetadata(ctx context.Context) context.Context { } // Starts a remote JupyterLab server to allow the user to connect to the codespace via JupyterLab in their browser -func (g *Client) StartJupyterServer() (port int, serverUrl string, err error) { - ctx, cancel := context.WithTimeout(context.Background(), requestTimeout) +func (g *Client) StartJupyterServer(ctx context.Context) (port int, serverUrl string, err error) { + ctx, cancel := context.WithTimeout(ctx, requestTimeout) ctx = g.appendMetadata(ctx) defer cancel() diff --git a/pkg/cmd/codespace/jupyter.go b/pkg/cmd/codespace/jupyter.go index b5ce34a9f..bc1d6d2fe 100644 --- a/pkg/cmd/codespace/jupyter.go +++ b/pkg/cmd/codespace/jupyter.go @@ -51,7 +51,7 @@ func (a *App) Jupyter(ctx context.Context, codespaceName string) (err error) { defer safeClose(client, &err) a.StartProgressIndicatorWithLabel("Starting JupyterLab on codespace") - serverPort, serverUrl, err := client.StartJupyterServer() + serverPort, serverUrl, err := client.StartJupyterServer(ctx) a.StopProgressIndicator() if err != nil { return fmt.Errorf("failed to start JupyterLab server: %w", err) From d02ff315e42ab129951c912328777ddd997aa7aa Mon Sep 17 00:00:00 2001 From: David Gardiner Date: Thu, 29 Sep 2022 10:27:17 -0700 Subject: [PATCH 06/44] Fix linting errors --- go.sum | 2 -- internal/codespaces/grpc/client.go | 7 ++++++- 2 files changed, 6 insertions(+), 3 deletions(-) diff --git a/go.sum b/go.sum index 4b79794c5..66d90b82e 100644 --- a/go.sum +++ b/go.sum @@ -498,7 +498,6 @@ google.golang.org/grpc v1.27.1/go.mod h1:qbnxyOmOxrQa7FizSgH+ReBfzJrCY1pSN7KXBS8 google.golang.org/grpc v1.28.0/go.mod h1:rpkK4SK4GF4Ach/+MFLZUBavHOvF2JJB5uozKKal+60= google.golang.org/grpc v1.29.1/go.mod h1:itym6AZVZYACWQqET3MqgPpjcuV5QH3BxFS3IjizoKk= google.golang.org/grpc v1.30.0/go.mod h1:N36X2cJ7JwdamYAgDz+s+rVMFjt3numwzf/HckM8pak= -google.golang.org/grpc v1.31.0 h1:T7P4R73V3SSDPhH7WW7ATbfViLtmamH0DKrP3f9AuDI= google.golang.org/grpc v1.31.0/go.mod h1:N36X2cJ7JwdamYAgDz+s+rVMFjt3numwzf/HckM8pak= google.golang.org/grpc v1.49.0 h1:WTLtQzmQori5FUH25Pq4WT22oCsv8USpQ+F6rqtsmxw= google.golang.org/grpc v1.49.0/go.mod h1:ZgQEeidpAuNRZ8iRrlBKXZQP1ghovWIVhdJRyCDK+GI= @@ -513,7 +512,6 @@ google.golang.org/protobuf v1.23.1-0.20200526195155-81db48ad09cc/go.mod h1:EGpAD google.golang.org/protobuf v1.24.0/go.mod h1:r/3tXBNzIEhYS9I1OUVjXDlt8tc493IdKGjtUeSXeh4= google.golang.org/protobuf v1.25.0/go.mod h1:9JNX74DMeImyA3h4bdi1ymwjUzf21/xIlbajtzgsN7c= google.golang.org/protobuf v1.26.0-rc.1/go.mod h1:jlhhOSvTdKEhbULTjvd4ARK9grFBp09yW+WbY/TyQbw= -google.golang.org/protobuf v1.26.0 h1:bxAC2xTBsZGibn2RTntX0oH50xLsqy1OxA9tTL3p/lk= google.golang.org/protobuf v1.26.0/go.mod h1:9q0QmTI4eRPtz6boOQmLYwt+qCgq0jsYwAQnmE0givc= google.golang.org/protobuf v1.27.1 h1:SnqbnDw1V7RiZcXPx5MEeqPv2s79L9i7BJUlG/+RurQ= google.golang.org/protobuf v1.27.1/go.mod h1:9q0QmTI4eRPtz6boOQmLYwt+qCgq0jsYwAQnmE0givc= diff --git a/internal/codespaces/grpc/client.go b/internal/codespaces/grpc/client.go index 5bfa179ba..ab7eeca5f 100644 --- a/internal/codespaces/grpc/client.go +++ b/internal/codespaces/grpc/client.go @@ -57,7 +57,12 @@ func Connect(ctx context.Context, session liveshareSession, token string) (*Clie }() // Attempt to connect to the given port - conn, err := grpc.Dial(fmt.Sprintf("127.0.0.1:%d", localGrpcServerPort), grpc.WithTimeout(connectionTimeout), grpc.WithTransportCredentials(insecure.NewCredentials()), grpc.WithBlock()) + opts := []grpc.DialOption{ + grpc.WithTransportCredentials(insecure.NewCredentials()), + grpc.WithBlock(), + } + ctx, _ = context.WithTimeout(ctx, connectionTimeout) + conn, err := grpc.DialContext(ctx, fmt.Sprintf("127.0.0.1:%d", localGrpcServerPort), opts...) if err != nil { return nil, err } From f947020fa5b957b89d4f7be200e70bf2f2a47b48 Mon Sep 17 00:00:00 2001 From: David Gardiner Date: Fri, 30 Sep 2022 11:55:57 -0700 Subject: [PATCH 07/44] Add grpc mock server + tests --- internal/codespaces/grpc/client.go | 21 ++++++-- internal/codespaces/grpc/client_test.go | 68 +++++++++++++++++++++++++ internal/codespaces/grpc/test/server.go | 52 +++++++++++++++++++ 3 files changed, 137 insertions(+), 4 deletions(-) create mode 100644 internal/codespaces/grpc/client_test.go create mode 100644 internal/codespaces/grpc/test/server.go diff --git a/internal/codespaces/grpc/client.go b/internal/codespaces/grpc/client.go index ab7eeca5f..1c8a632d6 100644 --- a/internal/codespaces/grpc/client.go +++ b/internal/codespaces/grpc/client.go @@ -41,7 +41,7 @@ type liveshareSession interface { StartSharing(context.Context, string, int) (liveshare.ChannelID, error) } -// Connects to the gRPC server on the given port +// Finds a free port to listen on and creates a new gRPC client that connects to that port func Connect(ctx context.Context, session liveshareSession, token string) (*Client, error) { listener, err := net.Listen("tcp", fmt.Sprintf("127.0.0.1:%d", 0)) if err != nil { @@ -49,20 +49,34 @@ func Connect(ctx context.Context, session liveshareSession, token string) (*Clie } // Tunnel the remote gRPC server port to the local port - localGrpcServerPort := listener.Addr().(*net.TCPAddr).Port + localPort := listener.Addr().(*net.TCPAddr).Port internalTunnelClosed := make(chan error, 1) go func() { fwd := liveshare.NewPortForwarder(session, codespacesInternalSessionName, codespacesInternalPort, true) internalTunnelClosed <- fwd.ForwardToListener(ctx, listener) }() + // Create the gRPC client + client, err := NewClient(ctx, session, token, localPort) + if err != nil { + return nil, fmt.Errorf("failed to create client: %w", err) + } + + // Attach the listener so we can close it later + client.listener = listener + + return client, err +} + +// Creates a new gRPC client that connects to the given port +func NewClient(ctx context.Context, session liveshareSession, token string, localPort int) (*Client, error) { // Attempt to connect to the given port opts := []grpc.DialOption{ grpc.WithTransportCredentials(insecure.NewCredentials()), grpc.WithBlock(), } ctx, _ = context.WithTimeout(ctx, connectionTimeout) - conn, err := grpc.DialContext(ctx, fmt.Sprintf("127.0.0.1:%d", localGrpcServerPort), opts...) + conn, err := grpc.DialContext(ctx, fmt.Sprintf("127.0.0.1:%d", localPort), opts...) if err != nil { return nil, err } @@ -70,7 +84,6 @@ func Connect(ctx context.Context, session liveshareSession, token string) (*Clie g := &Client{ conn: conn, token: token, - listener: listener, jupyterClient: jupyter.NewJupyterServerHostClient(conn), } diff --git a/internal/codespaces/grpc/client_test.go b/internal/codespaces/grpc/client_test.go new file mode 100644 index 000000000..03dc7405a --- /dev/null +++ b/internal/codespaces/grpc/client_test.go @@ -0,0 +1,68 @@ +package grpc + +import ( + "context" + "fmt" + "net" + "testing" + + "github.com/cli/cli/v2/internal/codespaces/grpc/test" +) + +func TestMain(m *testing.M) { + // Start the gRPC server in the background + go func() { + err := test.StartServer() + if err != nil { + panic(err) + } + }() + + m.Run() +} + +func connect(t *testing.T) (ctx context.Context, client *Client) { + ctx = context.Background() + client, err := NewClient(ctx, nil, "token", test.ServerPort) + client.listener = &net.TCPListener{} // mock listener so the close function doesn't panic + if err != nil { + t.Fatalf("error connecting to internal server: %v", err) + } + + return ctx, client +} + +// Test that the gRPC client returns the correct port and URL when the JupyterLab server starts successfully +func TestStartJupyterServerSuccess(t *testing.T) { + ctx, client := connect(t) + defer client.Close() + port, url, err := client.StartJupyterServer(ctx) + if err != nil { + t.Fatalf("expected %v, got %v", nil, err) + } + if port != test.JupyterPort { + t.Fatalf("expected %d, got %d", test.JupyterPort, port) + } + if url != test.JupyterServerUrl { + t.Fatalf("expected %s, got %s", test.JupyterServerUrl, url) + } +} + +// Test that the gRPC client returns an error when the JupyterLab server fails to start +func TestStartJupyterServerFailure(t *testing.T) { + ctx, client := connect(t) + defer client.Close() + test.JupyterMessage = "error message" + test.JupyterResult = false + errorMessage := fmt.Sprintf("failed to start JupyterLab: %s", test.JupyterMessage) + port, url, err := client.StartJupyterServer(ctx) + if err.Error() != errorMessage { + t.Fatalf("expected %v, got %v", errorMessage, err) + } + if port != 0 { + t.Fatalf("expected %d, got %d", 0, port) + } + if url != "" { + t.Fatalf("expected %s, got %s", "", url) + } +} diff --git a/internal/codespaces/grpc/test/server.go b/internal/codespaces/grpc/test/server.go new file mode 100644 index 000000000..50608a9fa --- /dev/null +++ b/internal/codespaces/grpc/test/server.go @@ -0,0 +1,52 @@ +package test + +import ( + "context" + "fmt" + "net" + "strconv" + + "github.com/cli/cli/v2/internal/codespaces/grpc/jupyter" + "google.golang.org/grpc" +) + +const ( + ServerPort = 50051 +) + +var ( + JupyterPort = 1234 + JupyterServerUrl = "http://localhost:1234?token=1234" + JupyterMessage = "" + JupyterResult = true +) + +type server struct { + jupyter.UnimplementedJupyterServerHostServer +} + +func (s *server) GetRunningServer(ctx context.Context, in *jupyter.GetRunningServerRequest) (*jupyter.GetRunningServerResponse, error) { + return &jupyter.GetRunningServerResponse{ + Port: strconv.Itoa(JupyterPort), + ServerUrl: JupyterServerUrl, + Message: JupyterMessage, + Result: JupyterResult, + }, nil +} + +// Starts the mock gRPC server listening on port 50051 +func StartServer() error { + listener, err := net.Listen("tcp", fmt.Sprintf("127.0.0.1:%d", ServerPort)) + if err != nil { + return fmt.Errorf("failed to listen: %v", err) + } + defer listener.Close() + + s := grpc.NewServer() + jupyter.RegisterJupyterServerHostServer(s, &server{}) + if err := s.Serve(listener); err != nil { + return fmt.Errorf("failed to serve: %v", err) + } + + return nil +} From 341fc6c3f7032a23fc1f2d031bbaa83f84462c6e Mon Sep 17 00:00:00 2001 From: David Gardiner Date: Mon, 3 Oct 2022 20:29:31 -0700 Subject: [PATCH 08/44] Mock session/channel for grpc tests --- internal/codespaces/grpc/client.go | 17 +++--------- internal/codespaces/grpc/client_test.go | 11 ++++---- internal/codespaces/grpc/test/channel.go | 34 ++++++++++++++++++++++++ internal/codespaces/grpc/test/session.go | 33 +++++++++++++++++++++++ 4 files changed, 76 insertions(+), 19 deletions(-) create mode 100644 internal/codespaces/grpc/test/channel.go create mode 100644 internal/codespaces/grpc/test/session.go diff --git a/internal/codespaces/grpc/client.go b/internal/codespaces/grpc/client.go index 1c8a632d6..7ceeac7d1 100644 --- a/internal/codespaces/grpc/client.go +++ b/internal/codespaces/grpc/client.go @@ -56,21 +56,9 @@ func Connect(ctx context.Context, session liveshareSession, token string) (*Clie internalTunnelClosed <- fwd.ForwardToListener(ctx, listener) }() - // Create the gRPC client - client, err := NewClient(ctx, session, token, localPort) - if err != nil { - return nil, fmt.Errorf("failed to create client: %w", err) - } + time.Sleep(time.Millisecond) - // Attach the listener so we can close it later - client.listener = listener - - return client, err -} - -// Creates a new gRPC client that connects to the given port -func NewClient(ctx context.Context, session liveshareSession, token string, localPort int) (*Client, error) { - // Attempt to connect to the given port + // Attempt to connect to the port opts := []grpc.DialOption{ grpc.WithTransportCredentials(insecure.NewCredentials()), grpc.WithBlock(), @@ -84,6 +72,7 @@ func NewClient(ctx context.Context, session liveshareSession, token string, loca g := &Client{ conn: conn, token: token, + listener: listener, jupyterClient: jupyter.NewJupyterServerHostClient(conn), } diff --git a/internal/codespaces/grpc/client_test.go b/internal/codespaces/grpc/client_test.go index 03dc7405a..0279803ea 100644 --- a/internal/codespaces/grpc/client_test.go +++ b/internal/codespaces/grpc/client_test.go @@ -3,7 +3,6 @@ package grpc import ( "context" "fmt" - "net" "testing" "github.com/cli/cli/v2/internal/codespaces/grpc/test" @@ -22,20 +21,23 @@ func TestMain(m *testing.M) { } func connect(t *testing.T) (ctx context.Context, client *Client) { + t.Helper() ctx = context.Background() - client, err := NewClient(ctx, nil, "token", test.ServerPort) - client.listener = &net.TCPListener{} // mock listener so the close function doesn't panic + client, err := Connect(ctx, &test.Session{}, "token") if err != nil { t.Fatalf("error connecting to internal server: %v", err) } + t.Cleanup(func() { + client.Close() + }) + return ctx, client } // Test that the gRPC client returns the correct port and URL when the JupyterLab server starts successfully func TestStartJupyterServerSuccess(t *testing.T) { ctx, client := connect(t) - defer client.Close() port, url, err := client.StartJupyterServer(ctx) if err != nil { t.Fatalf("expected %v, got %v", nil, err) @@ -51,7 +53,6 @@ func TestStartJupyterServerSuccess(t *testing.T) { // Test that the gRPC client returns an error when the JupyterLab server fails to start func TestStartJupyterServerFailure(t *testing.T) { ctx, client := connect(t) - defer client.Close() test.JupyterMessage = "error message" test.JupyterResult = false errorMessage := fmt.Sprintf("failed to start JupyterLab: %s", test.JupyterMessage) diff --git a/internal/codespaces/grpc/test/channel.go b/internal/codespaces/grpc/test/channel.go new file mode 100644 index 000000000..eef42c4aa --- /dev/null +++ b/internal/codespaces/grpc/test/channel.go @@ -0,0 +1,34 @@ +package test + +import ( + "io" + "net" +) + +type Channel struct { + conn net.Conn +} + +func (c *Channel) Read(data []byte) (int, error) { + return c.conn.Read(data) +} + +func (c *Channel) Write(data []byte) (int, error) { + return c.conn.Write(data) +} + +func (c *Channel) Close() error { + return c.conn.Close() +} + +func (c *Channel) CloseWrite() error { + return nil +} + +func (c *Channel) SendRequest(name string, wantReply bool, payload []byte) (bool, error) { + return false, nil +} + +func (c *Channel) Stderr() io.ReadWriter { + return nil +} diff --git a/internal/codespaces/grpc/test/session.go b/internal/codespaces/grpc/test/session.go new file mode 100644 index 000000000..e29027dc6 --- /dev/null +++ b/internal/codespaces/grpc/test/session.go @@ -0,0 +1,33 @@ +package test + +import ( + "context" + "fmt" + "log" + "net" + + "github.com/cli/cli/v2/pkg/liveshare" + "golang.org/x/crypto/ssh" +) + +type Session struct { +} + +func (s *Session) KeepAlive(reason string) { + return +} + +func (s *Session) StartSharing(ctx context.Context, sessionName string, port int) (liveshare.ChannelID, error) { + return liveshare.ChannelID{}, nil +} + +func (s *Session) OpenStreamingChannel(ctx context.Context, id liveshare.ChannelID) (ssh.Channel, error) { + dialer := net.Dialer{} + conn, err := dialer.Dial("tcp", fmt.Sprintf("127.0.0.1:%d", ServerPort)) + if err != nil { + log.Fatalf("failed to connect to the grpc server: %v", err) + } + return &Channel{ + conn: conn, + }, nil +} From 4a81e46c1ae3f52612a79883f368ef00da2105c5 Mon Sep 17 00:00:00 2001 From: JP Ungaretti Date: Fri, 7 Oct 2022 22:05:16 +0000 Subject: [PATCH 09/44] Add rebuilding state --- internal/codespaces/api/api.go | 2 ++ 1 file changed, 2 insertions(+) diff --git a/internal/codespaces/api/api.go b/internal/codespaces/api/api.go index b8d44d9e0..524c2dcee 100644 --- a/internal/codespaces/api/api.go +++ b/internal/codespaces/api/api.go @@ -210,6 +210,8 @@ const ( CodespaceStateShutdown = "Shutdown" // CodespaceStateStarting is the state for a starting codespace environment. CodespaceStateStarting = "Starting" + // CodespaceStateRebuilding is the state for a rebuilding codespace environment. + CodespaceStateRebuilding = "Rebuilding" ) type CodespaceConnection struct { From a090b17e382a7f82630090e335ebe11e2cbf2d31 Mon Sep 17 00:00:00 2001 From: David Gardiner Date: Fri, 7 Oct 2022 15:37:04 -0700 Subject: [PATCH 10/44] Ensure port is forwarded and server is shared --- internal/codespaces/grpc/client.go | 7 ++-- internal/codespaces/grpc/test/session.go | 1 + pkg/liveshare/port_forwarder.go | 49 ++++++++++++++++++++++++ 3 files changed, 54 insertions(+), 3 deletions(-) diff --git a/internal/codespaces/grpc/client.go b/internal/codespaces/grpc/client.go index 7ceeac7d1..cf8137006 100644 --- a/internal/codespaces/grpc/client.go +++ b/internal/codespaces/grpc/client.go @@ -49,14 +49,15 @@ func Connect(ctx context.Context, session liveshareSession, token string) (*Clie } // Tunnel the remote gRPC server port to the local port - localPort := listener.Addr().(*net.TCPAddr).Port + localAddress := fmt.Sprintf("127.0.0.1:%d", listener.Addr().(*net.TCPAddr).Port) internalTunnelClosed := make(chan error, 1) go func() { fwd := liveshare.NewPortForwarder(session, codespacesInternalSessionName, codespacesInternalPort, true) internalTunnelClosed <- fwd.ForwardToListener(ctx, listener) }() - time.Sleep(time.Millisecond) + // Ping the port to ensure that it is fully forwarded before continuing + liveshare.WaitForPortConnection(ctx, localAddress) // Attempt to connect to the port opts := []grpc.DialOption{ @@ -64,7 +65,7 @@ func Connect(ctx context.Context, session liveshareSession, token string) (*Clie grpc.WithBlock(), } ctx, _ = context.WithTimeout(ctx, connectionTimeout) - conn, err := grpc.DialContext(ctx, fmt.Sprintf("127.0.0.1:%d", localPort), opts...) + conn, err := grpc.DialContext(ctx, localAddress, opts...) if err != nil { return nil, err } diff --git a/internal/codespaces/grpc/test/session.go b/internal/codespaces/grpc/test/session.go index e29027dc6..ec4d69649 100644 --- a/internal/codespaces/grpc/test/session.go +++ b/internal/codespaces/grpc/test/session.go @@ -21,6 +21,7 @@ func (s *Session) StartSharing(ctx context.Context, sessionName string, port int return liveshare.ChannelID{}, nil } +// Creates mock SSH channel connected to the mock gRPC server func (s *Session) OpenStreamingChannel(ctx context.Context, id liveshare.ChannelID) (ssh.Channel, error) { dialer := net.Dialer{} conn, err := dialer.Dial("tcp", fmt.Sprintf("127.0.0.1:%d", ServerPort)) diff --git a/pkg/liveshare/port_forwarder.go b/pkg/liveshare/port_forwarder.go index f042eeaea..33dc6d1fa 100644 --- a/pkg/liveshare/port_forwarder.go +++ b/pkg/liveshare/port_forwarder.go @@ -5,9 +5,15 @@ import ( "fmt" "io" "net" + "time" "github.com/opentracing/opentracing-go" "golang.org/x/crypto/ssh" + "golang.org/x/sync/errgroup" +) + +const ( + connectionTimeout = 30 * time.Second ) type portForwardingSession interface { @@ -54,6 +60,12 @@ func (fwd *PortForwarder) ForwardToListener(ctx context.Context, listen net.List return err } + // Ping the port to ensure that it is fully forwarded before continuing + err = WaitForPortConnection(ctx, listen.Addr().String()) + if err != nil { + return err + } + errc := make(chan error, 1) sendError := func(err error) { // Use non-blocking send, to avoid goroutines getting @@ -99,6 +111,43 @@ func (fwd *PortForwarder) Forward(ctx context.Context, conn io.ReadWriteCloser) return awaitError(ctx, errc) } +// Connects to and pings a given address to ensure that the server is shared and the port is forwarded. +func WaitForPortConnection(ctx context.Context, address string) error { + waitCtx, cancel := context.WithTimeout(ctx, connectionTimeout) + g, waitCtx := errgroup.WithContext(waitCtx) + defer cancel() + + ticker := time.NewTicker(1 * time.Second) + defer ticker.Stop() + + g.Go(func() error { + for { + select { + case <-waitCtx.Done(): + return fmt.Errorf("timed out waiting for connection") + case <-ticker.C: + // Verify that the port can be connected to + conn, err := net.Dial("tcp", address) + if err != nil { + continue + } + + defer conn.Close() + + // Send a ping and make sure it succeed + _, err = conn.Write([]byte("ping")) + if err != nil { + continue + } + + return nil + } + } + }) + + return g.Wait() +} + func (fwd *PortForwarder) shareRemotePort(ctx context.Context) (ChannelID, error) { id, err := fwd.session.StartSharing(ctx, fwd.name, fwd.remotePort) if err != nil { From da91216c31a864c1ad13595d9e5635ec33848bb2 Mon Sep 17 00:00:00 2001 From: JP Ungaretti Date: Sat, 8 Oct 2022 00:04:45 +0000 Subject: [PATCH 11/44] Add new Rebuild function --- pkg/cmd/codespace/common.go | 1 + pkg/liveshare/session.go | 14 ++++++++++++++ pkg/liveshare/session_test.go | 18 ++++++++++++++++++ 3 files changed, 33 insertions(+) diff --git a/pkg/cmd/codespace/common.go b/pkg/cmd/codespace/common.go index 46e69d97c..a2d1a2316 100644 --- a/pkg/cmd/codespace/common.go +++ b/pkg/cmd/codespace/common.go @@ -66,6 +66,7 @@ type liveshareSession interface { StartSharing(context.Context, string, int) (liveshare.ChannelID, error) StartSSHServer(context.Context) (int, string, error) StartSSHServerWithOptions(context.Context, liveshare.StartSSHServerOptions) (int, string, error) + Rebuild(context.Context) error } // Connects to a codespace using Live Share and returns that session diff --git a/pkg/liveshare/session.go b/pkg/liveshare/session.go index f912fa5ea..8518afd56 100644 --- a/pkg/liveshare/session.go +++ b/pkg/liveshare/session.go @@ -123,6 +123,20 @@ func (s *Session) StartJupyterServer(ctx context.Context) (int, string, error) { return port, response.ServerUrl, nil } +func (s *Session) Rebuild(ctx context.Context) error { + var success bool + err := s.rpc.do(ctx, "IEnvironmentConfigurationService.rebuildContainer", []string{}, &success) + if err != nil { + return fmt.Errorf("invoking rebuild RPC: %w", err) + } + + if !success { + return fmt.Errorf("couldn't rebuild codespace") + } else { + return nil + } +} + // heartbeat runs until context cancellation, periodically checking whether there is a // reason to keep the connection alive, and if so, notifying the Live Share host to do so. // Heartbeat ensures it does not send more than one request every "interval" to ratelimit diff --git a/pkg/liveshare/session_test.go b/pkg/liveshare/session_test.go index cfe8ccd11..2160caf6e 100644 --- a/pkg/liveshare/session_test.go +++ b/pkg/liveshare/session_test.go @@ -399,6 +399,24 @@ func TestSessionHeartbeat(t *testing.T) { } } +func TestRebuild(t *testing.T) { + getSharedServers := func(conn *jsonrpc2.Conn, req *jsonrpc2.Request) (interface{}, error) { + return true, nil + } + testServer, session, err := makeMockSession( + livesharetest.WithService("IEnvironmentConfigurationService.rebuildContainer", getSharedServers), + ) + if err != nil { + t.Fatalf("creating mock session: %v", err) + } + defer testServer.Close() + + err = session.Rebuild(context.Background()) + if err != nil { + t.Fatalf("rebuilding codespace over mock session: %v", err) + } +} + type mockLogger struct { sync.Mutex buf *bytes.Buffer From 4c49fd3e64d39e74e9020309b86c112e94e4a1b0 Mon Sep 17 00:00:00 2001 From: JP Ungaretti Date: Sat, 8 Oct 2022 06:12:36 +0000 Subject: [PATCH 12/44] Add rebuild command --- pkg/cmd/codespace/rebuild.go | 57 +++++++++++++++++++++++++++++++ pkg/cmd/codespace/rebuild_test.go | 36 +++++++++++++++++++ pkg/cmd/codespace/root.go | 1 + 3 files changed, 94 insertions(+) create mode 100644 pkg/cmd/codespace/rebuild.go create mode 100644 pkg/cmd/codespace/rebuild_test.go diff --git a/pkg/cmd/codespace/rebuild.go b/pkg/cmd/codespace/rebuild.go new file mode 100644 index 000000000..56e841dfe --- /dev/null +++ b/pkg/cmd/codespace/rebuild.go @@ -0,0 +1,57 @@ +package codespace + +import ( + "context" + "fmt" + + "github.com/cli/cli/v2/internal/codespaces/api" + "github.com/spf13/cobra" +) + +func newRebuildCmd(app *App) *cobra.Command { + var codespace string + + rebuildCmd := &cobra.Command{ + Use: "rebuild", + Short: "Rebuild a codespace", + Args: cobra.NoArgs, + RunE: func(cmd *cobra.Command, args []string) error { + return app.Rebuild(cmd.Context(), codespace) + }, + } + + rebuildCmd.Flags().StringVarP(&codespace, "codespace", "c", "", "Name of the codespace") + + return rebuildCmd +} + +func (a *App) Rebuild(ctx context.Context, codespaceName string) (err error) { + ctx, cancel := context.WithCancel(ctx) + defer cancel() + + codespace, err := getOrChooseCodespace(ctx, a.apiClient, codespaceName) + if err != nil { + return err + } + + // Users can't change their codespace while it's rebuilding, so there's + // no need to execute this command. + if codespace.State == api.CodespaceStateRebuilding { + fmt.Fprintf(a.io.Out, "%s is already rebuilding\n", codespace.Name) + return nil + } + + session, err := startLiveShareSession(ctx, codespace, a, false, "") + if err != nil { + return err + } + defer safeClose(session, &err) + + err = session.Rebuild(ctx) + if err != nil { + return fmt.Errorf("couldn't rebuild codespace: %w", err) + } + + fmt.Fprintf(a.io.Out, "%s is rebuilding\n", codespace.Name) + return nil +} diff --git a/pkg/cmd/codespace/rebuild_test.go b/pkg/cmd/codespace/rebuild_test.go new file mode 100644 index 000000000..effaae28d --- /dev/null +++ b/pkg/cmd/codespace/rebuild_test.go @@ -0,0 +1,36 @@ +package codespace + +import ( + "context" + "testing" + + "github.com/cli/cli/v2/internal/codespaces/api" + "github.com/cli/cli/v2/pkg/iostreams" +) + +func TestAlreadyRebuildingCodespace(t *testing.T) { + app := testingRebuildApp() + + err := app.Rebuild(context.Background(), "rebuildingCodespace") + if err != nil { + t.Errorf("error rebuilding a codespace that is already rebuilding: %v", err) + } +} + +func testingRebuildApp() *App { + rebuildingCodespace := &api.Codespace{ + Name: "rebuildingCodespace", + State: api.CodespaceStateRebuilding, + } + apiMock := &apiClientMock{ + GetCodespaceFunc: func(_ context.Context, name string, _ bool) (*api.Codespace, error) { + if name == rebuildingCodespace.Name { + return rebuildingCodespace, nil + } + return nil, nil + }, + } + + ios, _, _, _ := iostreams.Test() + return NewApp(ios, nil, apiMock, nil) +} diff --git a/pkg/cmd/codespace/root.go b/pkg/cmd/codespace/root.go index d700664b1..8439430aa 100644 --- a/pkg/cmd/codespace/root.go +++ b/pkg/cmd/codespace/root.go @@ -22,6 +22,7 @@ func NewRootCmd(app *App) *cobra.Command { root.AddCommand(newCpCmd(app)) root.AddCommand(newStopCmd(app)) root.AddCommand(newSelectCmd(app)) + root.AddCommand(newRebuildCmd(app)) return root } From d05cdf5ff32629f8661066b1c4e4b67f79abad0a Mon Sep 17 00:00:00 2001 From: JP Ungaretti Date: Sat, 8 Oct 2022 06:30:42 +0000 Subject: [PATCH 13/44] Tidy up comments and errors --- pkg/cmd/codespace/rebuild.go | 7 +++---- pkg/cmd/codespace/rebuild_test.go | 2 +- pkg/liveshare/session.go | 6 +++--- pkg/liveshare/session_test.go | 2 +- 4 files changed, 8 insertions(+), 9 deletions(-) diff --git a/pkg/cmd/codespace/rebuild.go b/pkg/cmd/codespace/rebuild.go index 56e841dfe..9988f196a 100644 --- a/pkg/cmd/codespace/rebuild.go +++ b/pkg/cmd/codespace/rebuild.go @@ -34,8 +34,7 @@ func (a *App) Rebuild(ctx context.Context, codespaceName string) (err error) { return err } - // Users can't change their codespace while it's rebuilding, so there's - // no need to execute this command. + // There's no need to rebuild again because users can't modify their codespace while it rebuilds if codespace.State == api.CodespaceStateRebuilding { fmt.Fprintf(a.io.Out, "%s is already rebuilding\n", codespace.Name) return nil @@ -43,13 +42,13 @@ func (a *App) Rebuild(ctx context.Context, codespaceName string) (err error) { session, err := startLiveShareSession(ctx, codespace, a, false, "") if err != nil { - return err + return fmt.Errorf("starting Live Share session: %w", err) } defer safeClose(session, &err) err = session.Rebuild(ctx) if err != nil { - return fmt.Errorf("couldn't rebuild codespace: %w", err) + return fmt.Errorf("rebuilding codespace via session: %w", err) } fmt.Fprintf(a.io.Out, "%s is rebuilding\n", codespace.Name) diff --git a/pkg/cmd/codespace/rebuild_test.go b/pkg/cmd/codespace/rebuild_test.go index effaae28d..bb268efee 100644 --- a/pkg/cmd/codespace/rebuild_test.go +++ b/pkg/cmd/codespace/rebuild_test.go @@ -13,7 +13,7 @@ func TestAlreadyRebuildingCodespace(t *testing.T) { err := app.Rebuild(context.Background(), "rebuildingCodespace") if err != nil { - t.Errorf("error rebuilding a codespace that is already rebuilding: %v", err) + t.Errorf("rebuilding a codespace that was already rebuilding: %v", err) } } diff --git a/pkg/liveshare/session.go b/pkg/liveshare/session.go index 8518afd56..99f82af99 100644 --- a/pkg/liveshare/session.go +++ b/pkg/liveshare/session.go @@ -124,13 +124,13 @@ func (s *Session) StartJupyterServer(ctx context.Context) (int, string, error) { } func (s *Session) Rebuild(ctx context.Context) error { - var success bool - err := s.rpc.do(ctx, "IEnvironmentConfigurationService.rebuildContainer", []string{}, &success) + var rebuildSuccess bool + err := s.rpc.do(ctx, "IEnvironmentConfigurationService.rebuildContainer", []string{}, &rebuildSuccess) if err != nil { return fmt.Errorf("invoking rebuild RPC: %w", err) } - if !success { + if !rebuildSuccess { return fmt.Errorf("couldn't rebuild codespace") } else { return nil diff --git a/pkg/liveshare/session_test.go b/pkg/liveshare/session_test.go index 2160caf6e..1af35b2e0 100644 --- a/pkg/liveshare/session_test.go +++ b/pkg/liveshare/session_test.go @@ -413,7 +413,7 @@ func TestRebuild(t *testing.T) { err = session.Rebuild(context.Background()) if err != nil { - t.Fatalf("rebuilding codespace over mock session: %v", err) + t.Fatalf("rebuilding codespace via mock session: %v", err) } } From 30fcddeccb7a2b45c38dd52527f871f06ab6e464 Mon Sep 17 00:00:00 2001 From: JP Ungaretti Date: Sun, 9 Oct 2022 00:59:54 +0000 Subject: [PATCH 14/44] Use nil instead of []string{} --- pkg/liveshare/session.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pkg/liveshare/session.go b/pkg/liveshare/session.go index 99f82af99..386d4d3f2 100644 --- a/pkg/liveshare/session.go +++ b/pkg/liveshare/session.go @@ -125,7 +125,7 @@ func (s *Session) StartJupyterServer(ctx context.Context) (int, string, error) { func (s *Session) Rebuild(ctx context.Context) error { var rebuildSuccess bool - err := s.rpc.do(ctx, "IEnvironmentConfigurationService.rebuildContainer", []string{}, &rebuildSuccess) + err := s.rpc.do(ctx, "IEnvironmentConfigurationService.rebuildContainer", nil, &rebuildSuccess) if err != nil { return fmt.Errorf("invoking rebuild RPC: %w", err) } From b373fbbe4447b7740b86571ef7c5ad3166b8148d Mon Sep 17 00:00:00 2001 From: JP Ungaretti Date: Sun, 9 Oct 2022 01:00:14 +0000 Subject: [PATCH 15/44] Remove else block --- pkg/liveshare/session.go | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/pkg/liveshare/session.go b/pkg/liveshare/session.go index 386d4d3f2..ee97e52bb 100644 --- a/pkg/liveshare/session.go +++ b/pkg/liveshare/session.go @@ -132,9 +132,9 @@ func (s *Session) Rebuild(ctx context.Context) error { if !rebuildSuccess { return fmt.Errorf("couldn't rebuild codespace") - } else { - return nil } + + return nil } // heartbeat runs until context cancellation, periodically checking whether there is a From 6704f38ffcbcbd10d3473a628ba6c5fa430d730c Mon Sep 17 00:00:00 2001 From: JP Ungaretti Date: Sun, 9 Oct 2022 01:05:52 +0000 Subject: [PATCH 16/44] Verify that RPC was called --- pkg/liveshare/session_test.go | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/pkg/liveshare/session_test.go b/pkg/liveshare/session_test.go index 1af35b2e0..98110a0cd 100644 --- a/pkg/liveshare/session_test.go +++ b/pkg/liveshare/session_test.go @@ -400,7 +400,9 @@ func TestSessionHeartbeat(t *testing.T) { } func TestRebuild(t *testing.T) { + requestCount := 0 getSharedServers := func(conn *jsonrpc2.Conn, req *jsonrpc2.Request) (interface{}, error) { + requestCount++ return true, nil } testServer, session, err := makeMockSession( @@ -415,6 +417,10 @@ func TestRebuild(t *testing.T) { if err != nil { t.Fatalf("rebuilding codespace via mock session: %v", err) } + + if requestCount == 0 { + t.Fatalf("no requests were made") + } } type mockLogger struct { From 0e963ebd4f85be5216852dd0ae6d6224d67920e0 Mon Sep 17 00:00:00 2001 From: JP Ungaretti Date: Sun, 9 Oct 2022 01:11:04 +0000 Subject: [PATCH 17/44] Move codespace construction to its test --- pkg/cmd/codespace/rebuild_test.go | 16 ++++++++-------- 1 file changed, 8 insertions(+), 8 deletions(-) diff --git a/pkg/cmd/codespace/rebuild_test.go b/pkg/cmd/codespace/rebuild_test.go index bb268efee..fff40fe1b 100644 --- a/pkg/cmd/codespace/rebuild_test.go +++ b/pkg/cmd/codespace/rebuild_test.go @@ -9,7 +9,11 @@ import ( ) func TestAlreadyRebuildingCodespace(t *testing.T) { - app := testingRebuildApp() + rebuildingCodespace := &api.Codespace{ + Name: "rebuildingCodespace", + State: api.CodespaceStateRebuilding, + } + app := testingRebuildApp(*rebuildingCodespace) err := app.Rebuild(context.Background(), "rebuildingCodespace") if err != nil { @@ -17,15 +21,11 @@ func TestAlreadyRebuildingCodespace(t *testing.T) { } } -func testingRebuildApp() *App { - rebuildingCodespace := &api.Codespace{ - Name: "rebuildingCodespace", - State: api.CodespaceStateRebuilding, - } +func testingRebuildApp(mockCodespace api.Codespace) *App { apiMock := &apiClientMock{ GetCodespaceFunc: func(_ context.Context, name string, _ bool) (*api.Codespace, error) { - if name == rebuildingCodespace.Name { - return rebuildingCodespace, nil + if name == mockCodespace.Name { + return &mockCodespace, nil } return nil, nil }, From 0f41ccc472b4b4273973b05851bb2b9b787f296a Mon Sep 17 00:00:00 2001 From: David Gardiner Date: Mon, 10 Oct 2022 11:56:44 -0700 Subject: [PATCH 18/44] Have cilents call port connection function --- internal/codespaces/grpc/client.go | 12 ++++++++---- pkg/liveshare/port_forwarder.go | 19 +++---------------- 2 files changed, 11 insertions(+), 20 deletions(-) diff --git a/internal/codespaces/grpc/client.go b/internal/codespaces/grpc/client.go index cf8137006..ea8399683 100644 --- a/internal/codespaces/grpc/client.go +++ b/internal/codespaces/grpc/client.go @@ -19,8 +19,9 @@ import ( ) const ( - connectionTimeout = 5 * time.Second - requestTimeout = 30 * time.Second + serverConnectionTimeout = 5 * time.Second + requestTimeout = 30 * time.Second + portConnectionTimeout = 30 * time.Second ) const ( @@ -57,14 +58,17 @@ func Connect(ctx context.Context, session liveshareSession, token string) (*Clie }() // Ping the port to ensure that it is fully forwarded before continuing - liveshare.WaitForPortConnection(ctx, localAddress) + err = liveshare.WaitForPortConnection(ctx, localAddress, portConnectionTimeout) + if err != nil { + return nil, fmt.Errorf("failed to connect to local port: %w", err) + } // Attempt to connect to the port opts := []grpc.DialOption{ grpc.WithTransportCredentials(insecure.NewCredentials()), grpc.WithBlock(), } - ctx, _ = context.WithTimeout(ctx, connectionTimeout) + ctx, _ = context.WithTimeout(ctx, serverConnectionTimeout) conn, err := grpc.DialContext(ctx, localAddress, opts...) if err != nil { return nil, err diff --git a/pkg/liveshare/port_forwarder.go b/pkg/liveshare/port_forwarder.go index 33dc6d1fa..ec23fa3d3 100644 --- a/pkg/liveshare/port_forwarder.go +++ b/pkg/liveshare/port_forwarder.go @@ -12,10 +12,6 @@ import ( "golang.org/x/sync/errgroup" ) -const ( - connectionTimeout = 30 * time.Second -) - type portForwardingSession interface { StartSharing(context.Context, string, int) (ChannelID, error) OpenStreamingChannel(context.Context, ChannelID) (ssh.Channel, error) @@ -60,12 +56,6 @@ func (fwd *PortForwarder) ForwardToListener(ctx context.Context, listen net.List return err } - // Ping the port to ensure that it is fully forwarded before continuing - err = WaitForPortConnection(ctx, listen.Addr().String()) - if err != nil { - return err - } - errc := make(chan error, 1) sendError := func(err error) { // Use non-blocking send, to avoid goroutines getting @@ -112,20 +102,17 @@ func (fwd *PortForwarder) Forward(ctx context.Context, conn io.ReadWriteCloser) } // Connects to and pings a given address to ensure that the server is shared and the port is forwarded. -func WaitForPortConnection(ctx context.Context, address string) error { - waitCtx, cancel := context.WithTimeout(ctx, connectionTimeout) +func WaitForPortConnection(ctx context.Context, address string, timeout time.Duration) error { + waitCtx, cancel := context.WithTimeout(ctx, timeout) g, waitCtx := errgroup.WithContext(waitCtx) defer cancel() - ticker := time.NewTicker(1 * time.Second) - defer ticker.Stop() - g.Go(func() error { for { select { case <-waitCtx.Done(): return fmt.Errorf("timed out waiting for connection") - case <-ticker.C: + default: // Verify that the port can be connected to conn, err := net.Dial("tcp", address) if err != nil { From 982aa5ba82810043507711689706ba23afe94359 Mon Sep 17 00:00:00 2001 From: JP Ungaretti Date: Mon, 10 Oct 2022 21:01:29 +0000 Subject: [PATCH 19/44] Rename RPC command to RebuildContainer --- pkg/cmd/codespace/common.go | 2 +- pkg/cmd/codespace/rebuild.go | 2 +- pkg/liveshare/session.go | 2 +- pkg/liveshare/session_test.go | 2 +- 4 files changed, 4 insertions(+), 4 deletions(-) diff --git a/pkg/cmd/codespace/common.go b/pkg/cmd/codespace/common.go index a2d1a2316..0cdd69dfa 100644 --- a/pkg/cmd/codespace/common.go +++ b/pkg/cmd/codespace/common.go @@ -66,7 +66,7 @@ type liveshareSession interface { StartSharing(context.Context, string, int) (liveshare.ChannelID, error) StartSSHServer(context.Context) (int, string, error) StartSSHServerWithOptions(context.Context, liveshare.StartSSHServerOptions) (int, string, error) - Rebuild(context.Context) error + RebuildContainer(context.Context) error } // Connects to a codespace using Live Share and returns that session diff --git a/pkg/cmd/codespace/rebuild.go b/pkg/cmd/codespace/rebuild.go index 9988f196a..f2128d632 100644 --- a/pkg/cmd/codespace/rebuild.go +++ b/pkg/cmd/codespace/rebuild.go @@ -46,7 +46,7 @@ func (a *App) Rebuild(ctx context.Context, codespaceName string) (err error) { } defer safeClose(session, &err) - err = session.Rebuild(ctx) + err = session.RebuildContainer(ctx) if err != nil { return fmt.Errorf("rebuilding codespace via session: %w", err) } diff --git a/pkg/liveshare/session.go b/pkg/liveshare/session.go index ee97e52bb..40bdf287b 100644 --- a/pkg/liveshare/session.go +++ b/pkg/liveshare/session.go @@ -123,7 +123,7 @@ func (s *Session) StartJupyterServer(ctx context.Context) (int, string, error) { return port, response.ServerUrl, nil } -func (s *Session) Rebuild(ctx context.Context) error { +func (s *Session) RebuildContainer(ctx context.Context) error { var rebuildSuccess bool err := s.rpc.do(ctx, "IEnvironmentConfigurationService.rebuildContainer", nil, &rebuildSuccess) if err != nil { diff --git a/pkg/liveshare/session_test.go b/pkg/liveshare/session_test.go index 98110a0cd..06000c344 100644 --- a/pkg/liveshare/session_test.go +++ b/pkg/liveshare/session_test.go @@ -413,7 +413,7 @@ func TestRebuild(t *testing.T) { } defer testServer.Close() - err = session.Rebuild(context.Background()) + err = session.RebuildContainer(context.Background()) if err != nil { t.Fatalf("rebuilding codespace via mock session: %v", err) } From f89b1b6a453d5af4a7bd81655590e9d4e65c7037 Mon Sep 17 00:00:00 2001 From: David Gardiner Date: Mon, 10 Oct 2022 14:39:12 -0700 Subject: [PATCH 20/44] Create timeout context in caller --- internal/codespaces/grpc/client.go | 7 ++- internal/codespaces/grpc/client_test.go | 2 + internal/codespaces/grpc/test/session.go | 1 - pkg/cmd/codespace/jupyter.go | 2 +- pkg/liveshare/port_forwarder.go | 63 ++++++++++++------------ 5 files changed, 39 insertions(+), 36 deletions(-) diff --git a/internal/codespaces/grpc/client.go b/internal/codespaces/grpc/client.go index ea8399683..b4c5a0394 100644 --- a/internal/codespaces/grpc/client.go +++ b/internal/codespaces/grpc/client.go @@ -58,7 +58,9 @@ func Connect(ctx context.Context, session liveshareSession, token string) (*Clie }() // Ping the port to ensure that it is fully forwarded before continuing - err = liveshare.WaitForPortConnection(ctx, localAddress, portConnectionTimeout) + connctx, cancel := context.WithTimeout(ctx, portConnectionTimeout) + defer cancel() + err = liveshare.WaitForPortConnection(connctx, localAddress) if err != nil { return nil, fmt.Errorf("failed to connect to local port: %w", err) } @@ -68,7 +70,8 @@ func Connect(ctx context.Context, session liveshareSession, token string) (*Clie grpc.WithTransportCredentials(insecure.NewCredentials()), grpc.WithBlock(), } - ctx, _ = context.WithTimeout(ctx, serverConnectionTimeout) + ctx, cancel = context.WithTimeout(ctx, serverConnectionTimeout) + defer cancel() conn, err := grpc.DialContext(ctx, localAddress, opts...) if err != nil { return nil, err diff --git a/internal/codespaces/grpc/client_test.go b/internal/codespaces/grpc/client_test.go index 0279803ea..62a7b1723 100644 --- a/internal/codespaces/grpc/client_test.go +++ b/internal/codespaces/grpc/client_test.go @@ -3,6 +3,7 @@ package grpc import ( "context" "fmt" + "os" "testing" "github.com/cli/cli/v2/internal/codespaces/grpc/test" @@ -18,6 +19,7 @@ func TestMain(m *testing.M) { }() m.Run() + os.Exit(0) } func connect(t *testing.T) (ctx context.Context, client *Client) { diff --git a/internal/codespaces/grpc/test/session.go b/internal/codespaces/grpc/test/session.go index ec4d69649..70d81d41e 100644 --- a/internal/codespaces/grpc/test/session.go +++ b/internal/codespaces/grpc/test/session.go @@ -14,7 +14,6 @@ type Session struct { } func (s *Session) KeepAlive(reason string) { - return } func (s *Session) StartSharing(ctx context.Context, sessionName string, port int) (liveshare.ChannelID, error) { diff --git a/pkg/cmd/codespace/jupyter.go b/pkg/cmd/codespace/jupyter.go index bc1d6d2fe..c37a97ea7 100644 --- a/pkg/cmd/codespace/jupyter.go +++ b/pkg/cmd/codespace/jupyter.go @@ -44,13 +44,13 @@ func (a *App) Jupyter(ctx context.Context, codespaceName string) (err error) { } defer safeClose(session, &err) + a.StartProgressIndicatorWithLabel("Starting JupyterLab on codespace") client, err := grpc.Connect(ctx, session, codespace.Connection.SessionToken) if err != nil { return fmt.Errorf("error connecting to internal server: %w", err) } defer safeClose(client, &err) - a.StartProgressIndicatorWithLabel("Starting JupyterLab on codespace") serverPort, serverUrl, err := client.StartJupyterServer(ctx) a.StopProgressIndicator() if err != nil { diff --git a/pkg/liveshare/port_forwarder.go b/pkg/liveshare/port_forwarder.go index ec23fa3d3..923415c01 100644 --- a/pkg/liveshare/port_forwarder.go +++ b/pkg/liveshare/port_forwarder.go @@ -5,11 +5,9 @@ import ( "fmt" "io" "net" - "time" "github.com/opentracing/opentracing-go" "golang.org/x/crypto/ssh" - "golang.org/x/sync/errgroup" ) type portForwardingSession interface { @@ -101,38 +99,39 @@ func (fwd *PortForwarder) Forward(ctx context.Context, conn io.ReadWriteCloser) return awaitError(ctx, errc) } -// Connects to and pings a given address to ensure that the server is shared and the port is forwarded. -func WaitForPortConnection(ctx context.Context, address string, timeout time.Duration) error { - waitCtx, cancel := context.WithTimeout(ctx, timeout) - g, waitCtx := errgroup.WithContext(waitCtx) - defer cancel() - - g.Go(func() error { - for { - select { - case <-waitCtx.Done(): - return fmt.Errorf("timed out waiting for connection") - default: - // Verify that the port can be connected to - conn, err := net.Dial("tcp", address) - if err != nil { - continue - } - - defer conn.Close() - - // Send a ping and make sure it succeed - _, err = conn.Write([]byte("ping")) - if err != nil { - continue - } - - return nil +// Loops until we can connect to the address or the context is canceled. +func WaitForPortConnection(ctx context.Context, address string) error { + for { + select { + case <-ctx.Done(): + return ctx.Err() + default: + err := connectToAddr(address) + if err != nil { + continue } - } - }) - return g.Wait() + return nil // success + } + } +} + +// Connects to and pings a given address to ensure that the server is shared and the port is forwarded. +func connectToAddr(address string) error { + // Verify that the port can be connected to + conn, err := net.Dial("tcp", address) + if err != nil { + return err + } + defer conn.Close() + + // Send a ping and make sure it succeed + _, err = conn.Write([]byte("ping")) + if err != nil { + return err + } + + return nil } func (fwd *PortForwarder) shareRemotePort(ctx context.Context) (ChannelID, error) { From 331dc24f07b5d43838740bf50b550daec77f58a8 Mon Sep 17 00:00:00 2001 From: Ariel Deitcher <1149246+mntlty@users.noreply.github.com> Date: Tue, 11 Oct 2022 05:40:16 -0700 Subject: [PATCH 21/44] show pull request merged state on search (#6411) --- pkg/cmd/search/shared/shared.go | 6 +-- pkg/cmd/search/shared/shared_test.go | 20 +++++----- pkg/search/result.go | 58 ++++++++++++++++++---------- pkg/search/result_test.go | 20 ++++++++++ 4 files changed, 70 insertions(+), 34 deletions(-) diff --git a/pkg/cmd/search/shared/shared.go b/pkg/cmd/search/shared/shared.go index f670548b3..d095e8769 100644 --- a/pkg/cmd/search/shared/shared.go +++ b/pkg/cmd/search/shared/shared.go @@ -114,12 +114,12 @@ func displayIssueResults(io *iostreams.IOStreams, now time.Time, et EntityType, issueNum = "#" + issueNum } if issue.IsPullRequest() { - tp.AddField(issueNum, nil, cs.ColorFromString(colorForPRState(issue.State))) + tp.AddField(issueNum, nil, cs.ColorFromString(colorForPRState(issue.State()))) } else { - tp.AddField(issueNum, nil, cs.ColorFromString(colorForIssueState(issue.State, issue.StateReason))) + tp.AddField(issueNum, nil, cs.ColorFromString(colorForIssueState(issue.State(), issue.StateReason))) } if !tp.IsTTY() { - tp.AddField(issue.State, nil, nil) + tp.AddField(issue.State(), nil, nil) } tp.AddField(text.RemoveExcessiveWhitespace(issue.Title), nil, nil) tp.AddField(listIssueLabels(&issue, cs, tp.IsTTY()), nil, nil) diff --git a/pkg/cmd/search/shared/shared_test.go b/pkg/cmd/search/shared/shared_test.go index fe6c8c57b..8c4d4ca45 100644 --- a/pkg/cmd/search/shared/shared_test.go +++ b/pkg/cmd/search/shared/shared_test.go @@ -54,9 +54,9 @@ func TestSearchIssues(t *testing.T) { return search.IssuesResult{ IncompleteResults: false, Items: []search.Issue{ - {RepositoryURL: "github.com/test/cli", Number: 123, State: "open", Title: "something broken", Labels: []search.Label{{Name: "bug"}, {Name: "p1"}}, UpdatedAt: updatedAt}, - {RepositoryURL: "github.com/what/what", Number: 456, State: "closed", Title: "feature request", Labels: []search.Label{{Name: "enhancement"}}, UpdatedAt: updatedAt}, - {RepositoryURL: "github.com/blah/test", Number: 789, State: "open", Title: "some title", UpdatedAt: updatedAt}, + {RepositoryURL: "github.com/test/cli", Number: 123, StateInternal: "open", Title: "something broken", Labels: []search.Label{{Name: "bug"}, {Name: "p1"}}, UpdatedAt: updatedAt}, + {RepositoryURL: "github.com/what/what", Number: 456, StateInternal: "closed", Title: "feature request", Labels: []search.Label{{Name: "enhancement"}}, UpdatedAt: updatedAt}, + {RepositoryURL: "github.com/blah/test", Number: 789, StateInternal: "open", Title: "some title", UpdatedAt: updatedAt}, }, Total: 300, }, nil @@ -76,8 +76,8 @@ func TestSearchIssues(t *testing.T) { return search.IssuesResult{ IncompleteResults: false, Items: []search.Issue{ - {RepositoryURL: "github.com/test/cli", Number: 123, State: "open", Title: "bug", Labels: []search.Label{{Name: "bug"}, {Name: "p1"}}, UpdatedAt: updatedAt}, - {RepositoryURL: "github.com/what/what", Number: 456, State: "open", Title: "fix bug", Labels: []search.Label{{Name: "fix"}}, PullRequestLinks: search.PullRequestLinks{URL: "someurl"}, UpdatedAt: updatedAt}, + {RepositoryURL: "github.com/test/cli", Number: 123, StateInternal: "open", Title: "bug", Labels: []search.Label{{Name: "bug"}, {Name: "p1"}}, UpdatedAt: updatedAt}, + {RepositoryURL: "github.com/what/what", Number: 456, StateInternal: "open", Title: "fix bug", Labels: []search.Label{{Name: "fix"}}, PullRequest: search.PullRequest{URL: "someurl"}, UpdatedAt: updatedAt}, }, Total: 300, }, nil @@ -97,9 +97,9 @@ func TestSearchIssues(t *testing.T) { return search.IssuesResult{ IncompleteResults: false, Items: []search.Issue{ - {RepositoryURL: "github.com/test/cli", Number: 123, State: "open", Title: "something broken", Labels: []search.Label{{Name: "bug"}, {Name: "p1"}}, UpdatedAt: updatedAt}, - {RepositoryURL: "github.com/what/what", Number: 456, State: "closed", Title: "feature request", Labels: []search.Label{{Name: "enhancement"}}, UpdatedAt: updatedAt}, - {RepositoryURL: "github.com/blah/test", Number: 789, State: "open", Title: "some title", UpdatedAt: updatedAt}, + {RepositoryURL: "github.com/test/cli", Number: 123, StateInternal: "open", Title: "something broken", Labels: []search.Label{{Name: "bug"}, {Name: "p1"}}, UpdatedAt: updatedAt}, + {RepositoryURL: "github.com/what/what", Number: 456, StateInternal: "closed", Title: "feature request", Labels: []search.Label{{Name: "enhancement"}}, UpdatedAt: updatedAt}, + {RepositoryURL: "github.com/blah/test", Number: 789, StateInternal: "open", Title: "some title", UpdatedAt: updatedAt}, }, Total: 300, }, nil @@ -118,8 +118,8 @@ func TestSearchIssues(t *testing.T) { return search.IssuesResult{ IncompleteResults: false, Items: []search.Issue{ - {RepositoryURL: "github.com/test/cli", Number: 123, State: "open", Title: "bug", Labels: []search.Label{{Name: "bug"}, {Name: "p1"}}, UpdatedAt: updatedAt}, - {RepositoryURL: "github.com/what/what", Number: 456, State: "open", Title: "fix bug", Labels: []search.Label{{Name: "fix"}}, PullRequestLinks: search.PullRequestLinks{URL: "someurl"}, UpdatedAt: updatedAt}, + {RepositoryURL: "github.com/test/cli", Number: 123, StateInternal: "open", Title: "bug", Labels: []search.Label{{Name: "bug"}, {Name: "p1"}}, UpdatedAt: updatedAt}, + {RepositoryURL: "github.com/what/what", Number: 456, StateInternal: "open", Title: "fix bug", Labels: []search.Label{{Name: "fix"}}, PullRequest: search.PullRequest{URL: "someurl"}, UpdatedAt: updatedAt}, }, Total: 300, }, nil diff --git a/pkg/search/result.go b/pkg/search/result.go index 4447f48ee..6ef1b8bc0 100644 --- a/pkg/search/result.go +++ b/pkg/search/result.go @@ -117,28 +117,42 @@ type User struct { } type Issue struct { - Assignees []User `json:"assignees"` - Author User `json:"user"` - AuthorAssociation string `json:"author_association"` - Body string `json:"body"` - ClosedAt time.Time `json:"closed_at"` - CommentsCount int `json:"comments"` - CreatedAt time.Time `json:"created_at"` - ID string `json:"node_id"` - Labels []Label `json:"labels"` - IsLocked bool `json:"locked"` - Number int `json:"number"` - PullRequestLinks PullRequestLinks `json:"pull_request"` - RepositoryURL string `json:"repository_url"` - State string `json:"state"` - StateReason string `json:"state_reason"` - Title string `json:"title"` - URL string `json:"html_url"` - UpdatedAt time.Time `json:"updated_at"` + Assignees []User `json:"assignees"` + Author User `json:"user"` + AuthorAssociation string `json:"author_association"` + Body string `json:"body"` + ClosedAt time.Time `json:"closed_at"` + CommentsCount int `json:"comments"` + CreatedAt time.Time `json:"created_at"` + ID string `json:"node_id"` + Labels []Label `json:"labels"` + IsLocked bool `json:"locked"` + Number int `json:"number"` + PullRequest PullRequest `json:"pull_request"` + RepositoryURL string `json:"repository_url"` + // StateInternal should not be used directly. Use State() instead. + StateInternal string `json:"state"` + StateReason string `json:"state_reason"` + Title string `json:"title"` + URL string `json:"html_url"` + UpdatedAt time.Time `json:"updated_at"` } -type PullRequestLinks struct { - URL string `json:"html_url"` +type PullRequest struct { + URL string `json:"html_url"` + MergedAt time.Time `json:"merged_at"` +} + +// the state of an issue or a pull request, +// may be either open or closed. +// for a pull request, the "merged" state is +// inferred from a value for merged_at and +// which we take return instead of the "closed" state. +func (issue Issue) State() string { + if !issue.PullRequest.MergedAt.IsZero() { + return "merged" + } + return issue.StateInternal } type Label struct { @@ -175,7 +189,7 @@ func (repo Repository) ExportData(fields []string) map[string]interface{} { } func (issue Issue) IsPullRequest() bool { - return issue.PullRequestLinks.URL != "" + return issue.PullRequest.URL != "" } func (issue Issue) ExportData(fields []string) map[string]interface{} { @@ -220,6 +234,8 @@ func (issue Issue) ExportData(fields []string) map[string]interface{} { "name": name, "nameWithOwner": nameWithOwner, } + case "state": + data[f] = issue.State() default: sf := fieldByName(v, f) data[f] = sf.Interface() diff --git a/pkg/search/result_test.go b/pkg/search/result_test.go index ba173e591..c933eb304 100644 --- a/pkg/search/result_test.go +++ b/pkg/search/result_test.go @@ -68,6 +68,26 @@ func TestIssueExportData(t *testing.T) { }, output: `{"assignees":[{"id":"","login":"test","type":""}],"body":"body","commentsCount":1,"isLocked":true,"labels":[{"color":"","description":"","id":"","name":"label1"},{"color":"","description":"","id":"","name":"label2"}],"repository":{"name":"repo","nameWithOwner":"owner/repo"},"title":"title","updatedAt":"2021-02-28T12:30:00Z"}`, }, + { + name: "state when issue", + fields: []string{"isPullRequest", "state"}, + issue: Issue{ + StateInternal: "closed", + }, + output: `{"isPullRequest":false,"state":"closed"}`, + }, + { + name: "state when pull request", + fields: []string{"isPullRequest", "state"}, + issue: Issue{ + PullRequest: PullRequest{ + MergedAt: time.Now(), + URL: "a-url", + }, + StateInternal: "closed", + }, + output: `{"isPullRequest":true,"state":"merged"}`, + }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { From e11b43f8f6d01c768784dc594fad5a22b12c6c89 Mon Sep 17 00:00:00 2001 From: Jose Garcia Date: Tue, 11 Oct 2022 08:55:14 -0400 Subject: [PATCH 22/44] Fixes for handling the grpc client lifecyle --- internal/codespaces/grpc/client.go | 65 +++++++++++++++--------- internal/codespaces/grpc/client_test.go | 55 +++++++++++--------- internal/codespaces/grpc/test/server.go | 20 ++++++-- internal/codespaces/grpc/test/session.go | 16 +++--- pkg/cmd/codespace/jupyter.go | 31 +++++++++-- pkg/liveshare/port_forwarder.go | 35 ------------- 6 files changed, 123 insertions(+), 99 deletions(-) diff --git a/internal/codespaces/grpc/client.go b/internal/codespaces/grpc/client.go index b4c5a0394..5ced382f8 100644 --- a/internal/codespaces/grpc/client.go +++ b/internal/codespaces/grpc/client.go @@ -19,9 +19,8 @@ import ( ) const ( - serverConnectionTimeout = 5 * time.Second - requestTimeout = 30 * time.Second - portConnectionTimeout = 30 * time.Second + ConnectionTimeout = 5 * time.Second + RequestTimeout = 30 * time.Second ) const ( @@ -34,6 +33,7 @@ type Client struct { token string listener net.Listener jupyterClient jupyter.JupyterServerHostClient + cancelPF context.CancelFunc } type liveshareSession interface { @@ -49,32 +49,50 @@ func Connect(ctx context.Context, session liveshareSession, token string) (*Clie return nil, fmt.Errorf("failed to listen to local port over tcp: %w", err) } - // Tunnel the remote gRPC server port to the local port - localAddress := fmt.Sprintf("127.0.0.1:%d", listener.Addr().(*net.TCPAddr).Port) - internalTunnelClosed := make(chan error, 1) - go func() { - fwd := liveshare.NewPortForwarder(session, codespacesInternalSessionName, codespacesInternalPort, true) - internalTunnelClosed <- fwd.ForwardToListener(ctx, listener) + // Create a cancelable context to be able to cancel background tasks + // if we encounter an error while connecting to the gRPC server + connectctx, cancel := context.WithCancel(context.Background()) + defer func() { + if err != nil { + cancel() + } }() - // Ping the port to ensure that it is fully forwarded before continuing - connctx, cancel := context.WithTimeout(ctx, portConnectionTimeout) - defer cancel() - err = liveshare.WaitForPortConnection(connctx, localAddress) - if err != nil { - return nil, fmt.Errorf("failed to connect to local port: %w", err) - } + // Ensure we close the port forwarder if we encounter an error + // or once the gRPC connection is closed. pfcancel is retained + // to close the PF whenever we close the gRPC connection. + pfctx, pfcancel := context.WithCancel(connectctx) + + ch := make(chan error, 2) // Buffered channel to ensure we don't block on the goroutine + + // Tunnel the remote gRPC server port to the local port + localAddress := fmt.Sprintf("127.0.0.1:%d", listener.Addr().(*net.TCPAddr).Port) + go func() { + fwd := liveshare.NewPortForwarder(session, codespacesInternalSessionName, codespacesInternalPort, true) + ch <- fwd.ForwardToListener(pfctx, listener) + }() // Attempt to connect to the port opts := []grpc.DialOption{ grpc.WithTransportCredentials(insecure.NewCredentials()), grpc.WithBlock(), } - ctx, cancel = context.WithTimeout(ctx, serverConnectionTimeout) - defer cancel() - conn, err := grpc.DialContext(ctx, localAddress, opts...) - if err != nil { - return nil, err + + var conn *grpc.ClientConn + + go func() { + conn, err = grpc.DialContext(connectctx, localAddress, opts...) + ch <- err // nil if we successfully connected + }() + + // Wait for the connection to be established or for the context to be cancelled + select { + case <-ctx.Done(): + return nil, ctx.Err() + case err := <-ch: + if err != nil { + return nil, err + } } g := &Client{ @@ -82,6 +100,7 @@ func Connect(ctx context.Context, session liveshareSession, token string) (*Clie token: token, listener: listener, jupyterClient: jupyter.NewJupyterServerHostClient(conn), + cancelPF: pfcancel, } return g, nil @@ -89,6 +108,8 @@ func Connect(ctx context.Context, session liveshareSession, token string) (*Clie // Closes the gRPC connection func (g *Client) Close() error { + g.cancelPF() + // Closing the local listener effectively closes the gRPC connection if err := g.listener.Close(); err != nil { g.conn.Close() // If we fail to close the listener, explicitly close the gRPC connection and ignore any error @@ -105,9 +126,7 @@ func (g *Client) appendMetadata(ctx context.Context) context.Context { // Starts a remote JupyterLab server to allow the user to connect to the codespace via JupyterLab in their browser func (g *Client) StartJupyterServer(ctx context.Context) (port int, serverUrl string, err error) { - ctx, cancel := context.WithTimeout(ctx, requestTimeout) ctx = g.appendMetadata(ctx) - defer cancel() response, err := g.jupyterClient.GetRunningServer(ctx, &jupyter.GetRunningServerRequest{}) if err != nil { diff --git a/internal/codespaces/grpc/client_test.go b/internal/codespaces/grpc/client_test.go index 62a7b1723..d905c0e29 100644 --- a/internal/codespaces/grpc/client_test.go +++ b/internal/codespaces/grpc/client_test.go @@ -3,29 +3,35 @@ package grpc import ( "context" "fmt" - "os" + "log" "testing" - "github.com/cli/cli/v2/internal/codespaces/grpc/test" + grpctest "github.com/cli/cli/v2/internal/codespaces/grpc/test" ) -func TestMain(m *testing.M) { +func startServer(t *testing.T) { + t.Helper() + + ctx, cancel := context.WithCancel(context.Background()) + // Start the gRPC server in the background go func() { - err := test.StartServer() - if err != nil { - panic(err) + err := grpctest.StartServer(ctx) + if err != nil && err != context.Canceled { + log.Println(fmt.Errorf("error starting test server: %v", err)) } }() - m.Run() - os.Exit(0) + // Stop the gRPC server when the test is done + t.Cleanup(func() { + cancel() + }) } -func connect(t *testing.T) (ctx context.Context, client *Client) { +func connect(t *testing.T) (client *Client) { t.Helper() - ctx = context.Background() - client, err := Connect(ctx, &test.Session{}, "token") + + client, err := Connect(context.Background(), &grpctest.Session{}, "token") if err != nil { t.Fatalf("error connecting to internal server: %v", err) } @@ -34,31 +40,34 @@ func connect(t *testing.T) (ctx context.Context, client *Client) { client.Close() }) - return ctx, client + return client } // Test that the gRPC client returns the correct port and URL when the JupyterLab server starts successfully func TestStartJupyterServerSuccess(t *testing.T) { - ctx, client := connect(t) - port, url, err := client.StartJupyterServer(ctx) + startServer(t) + client := connect(t) + + port, url, err := client.StartJupyterServer(context.Background()) if err != nil { t.Fatalf("expected %v, got %v", nil, err) } - if port != test.JupyterPort { - t.Fatalf("expected %d, got %d", test.JupyterPort, port) + if port != grpctest.JupyterPort { + t.Fatalf("expected %d, got %d", grpctest.JupyterPort, port) } - if url != test.JupyterServerUrl { - t.Fatalf("expected %s, got %s", test.JupyterServerUrl, url) + if url != grpctest.JupyterServerUrl { + t.Fatalf("expected %s, got %s", grpctest.JupyterServerUrl, url) } } // Test that the gRPC client returns an error when the JupyterLab server fails to start func TestStartJupyterServerFailure(t *testing.T) { - ctx, client := connect(t) - test.JupyterMessage = "error message" - test.JupyterResult = false - errorMessage := fmt.Sprintf("failed to start JupyterLab: %s", test.JupyterMessage) - port, url, err := client.StartJupyterServer(ctx) + startServer(t) + client := connect(t) + grpctest.JupyterMessage = "error message" + grpctest.JupyterResult = false + errorMessage := fmt.Sprintf("failed to start JupyterLab: %s", grpctest.JupyterMessage) + port, url, err := client.StartJupyterServer(context.Background()) if err.Error() != errorMessage { t.Fatalf("expected %v, got %v", errorMessage, err) } diff --git a/internal/codespaces/grpc/test/server.go b/internal/codespaces/grpc/test/server.go index 50608a9fa..8af5efc29 100644 --- a/internal/codespaces/grpc/test/server.go +++ b/internal/codespaces/grpc/test/server.go @@ -35,7 +35,7 @@ func (s *server) GetRunningServer(ctx context.Context, in *jupyter.GetRunningSer } // Starts the mock gRPC server listening on port 50051 -func StartServer() error { +func StartServer(ctx context.Context) error { listener, err := net.Listen("tcp", fmt.Sprintf("127.0.0.1:%d", ServerPort)) if err != nil { return fmt.Errorf("failed to listen: %v", err) @@ -44,9 +44,19 @@ func StartServer() error { s := grpc.NewServer() jupyter.RegisterJupyterServerHostServer(s, &server{}) - if err := s.Serve(listener); err != nil { - return fmt.Errorf("failed to serve: %v", err) - } - return nil + ch := make(chan error, 1) + go func() { + if err := s.Serve(listener); err != nil { + ch <- fmt.Errorf("failed to serve: %v", err) + } + }() + + select { + case <-ctx.Done(): + s.Stop() + return ctx.Err() + case err := <-ch: + return err + } } diff --git a/internal/codespaces/grpc/test/session.go b/internal/codespaces/grpc/test/session.go index 70d81d41e..aba0f17ee 100644 --- a/internal/codespaces/grpc/test/session.go +++ b/internal/codespaces/grpc/test/session.go @@ -3,7 +3,6 @@ package test import ( "context" "fmt" - "log" "net" "github.com/cli/cli/v2/pkg/liveshare" @@ -11,23 +10,22 @@ import ( ) type Session struct { + channel ssh.Channel } func (s *Session) KeepAlive(reason string) { } func (s *Session) StartSharing(ctx context.Context, sessionName string, port int) (liveshare.ChannelID, error) { + conn, err := net.Dial("tcp", fmt.Sprintf("127.0.0.1:%d", ServerPort)) + if err != nil { + return liveshare.ChannelID{}, err + } + s.channel = &Channel{conn} return liveshare.ChannelID{}, nil } // Creates mock SSH channel connected to the mock gRPC server func (s *Session) OpenStreamingChannel(ctx context.Context, id liveshare.ChannelID) (ssh.Channel, error) { - dialer := net.Dialer{} - conn, err := dialer.Dial("tcp", fmt.Sprintf("127.0.0.1:%d", ServerPort)) - if err != nil { - log.Fatalf("failed to connect to the grpc server: %v", err) - } - return &Channel{ - conn: conn, - }, nil + return s.channel, nil } diff --git a/pkg/cmd/codespace/jupyter.go b/pkg/cmd/codespace/jupyter.go index c37a97ea7..928dc8871 100644 --- a/pkg/cmd/codespace/jupyter.go +++ b/pkg/cmd/codespace/jupyter.go @@ -45,17 +45,17 @@ func (a *App) Jupyter(ctx context.Context, codespaceName string) (err error) { defer safeClose(session, &err) a.StartProgressIndicatorWithLabel("Starting JupyterLab on codespace") - client, err := grpc.Connect(ctx, session, codespace.Connection.SessionToken) + client, err := connectToGRPCServer(ctx, session, codespace.Connection.SessionToken) if err != nil { - return fmt.Errorf("error connecting to internal server: %w", err) + return fmt.Errorf("failed to connect to internal server: %w", err) } defer safeClose(client, &err) - serverPort, serverUrl, err := client.StartJupyterServer(ctx) - a.StopProgressIndicator() + serverPort, serverUrl, err := startJupyterServer(ctx, client) if err != nil { return fmt.Errorf("failed to start JupyterLab server: %w", err) } + a.StopProgressIndicator() // Pass 0 to pick a random port listen, err := net.Listen("tcp", fmt.Sprintf("127.0.0.1:%d", 0)) @@ -87,3 +87,26 @@ func (a *App) Jupyter(ctx context.Context, codespaceName string) (err error) { return nil // success } } + +func connectToGRPCServer(ctx context.Context, session liveshareSession, token string) (*grpc.Client, error) { + ctx, _ = context.WithTimeout(ctx, grpc.ConnectionTimeout) + + client, err := grpc.Connect(ctx, session, token) + if err != nil { + return nil, fmt.Errorf("error connecting to internal server: %w", err) + } + + return client, nil +} + +func startJupyterServer(ctx context.Context, client *grpc.Client) (int, string, error) { + ctx, cancel := context.WithTimeout(ctx, grpc.RequestTimeout) + defer cancel() + + serverPort, serverUrl, err := client.StartJupyterServer(ctx) + if err != nil { + return 0, "", fmt.Errorf("failed to start JupyterLab server: %w", err) + } + + return serverPort, serverUrl, nil +} diff --git a/pkg/liveshare/port_forwarder.go b/pkg/liveshare/port_forwarder.go index 923415c01..f042eeaea 100644 --- a/pkg/liveshare/port_forwarder.go +++ b/pkg/liveshare/port_forwarder.go @@ -99,41 +99,6 @@ func (fwd *PortForwarder) Forward(ctx context.Context, conn io.ReadWriteCloser) return awaitError(ctx, errc) } -// Loops until we can connect to the address or the context is canceled. -func WaitForPortConnection(ctx context.Context, address string) error { - for { - select { - case <-ctx.Done(): - return ctx.Err() - default: - err := connectToAddr(address) - if err != nil { - continue - } - - return nil // success - } - } -} - -// Connects to and pings a given address to ensure that the server is shared and the port is forwarded. -func connectToAddr(address string) error { - // Verify that the port can be connected to - conn, err := net.Dial("tcp", address) - if err != nil { - return err - } - defer conn.Close() - - // Send a ping and make sure it succeed - _, err = conn.Write([]byte("ping")) - if err != nil { - return err - } - - return nil -} - func (fwd *PortForwarder) shareRemotePort(ctx context.Context) (ChannelID, error) { id, err := fwd.session.StartSharing(ctx, fwd.name, fwd.remotePort) if err != nil { From 88775a27a8ff6111e3192fa2c8585bd5b782abc8 Mon Sep 17 00:00:00 2001 From: JP Ungaretti <19893438+jungaretti@users.noreply.github.com> Date: Tue, 11 Oct 2022 06:12:52 -0700 Subject: [PATCH 23/44] Upgrade devcontainer config (#6405) --- .devcontainer/Dockerfile | 5 ----- .devcontainer/devcontainer.json | 30 ++++++++++++++++-------------- .vscode/settings.json | 5 ----- 3 files changed, 16 insertions(+), 24 deletions(-) delete mode 100644 .devcontainer/Dockerfile delete mode 100644 .vscode/settings.json diff --git a/.devcontainer/Dockerfile b/.devcontainer/Dockerfile deleted file mode 100644 index 34448d38b..000000000 --- a/.devcontainer/Dockerfile +++ /dev/null @@ -1,5 +0,0 @@ -# See here for image contents: https://github.com/microsoft/vscode-dev-containers/tree/v0.234.0/containers/go/.devcontainer/base.Dockerfile - -# VARIANT Defined in devcontainer.json -ARG VARIANT="1.18" -FROM mcr.microsoft.com/vscode/devcontainers/go:0-${VARIANT} diff --git a/.devcontainer/devcontainer.json b/.devcontainer/devcontainer.json index cdb10785e..10bf64350 100644 --- a/.devcontainer/devcontainer.json +++ b/.devcontainer/devcontainer.json @@ -1,22 +1,24 @@ { - "extensions": [ - "golang.go" - ], - "build": { - "dockerfile": "Dockerfile", - "args": { - "VARIANT": "1.18" - } + "image": "mcr.microsoft.com/devcontainers/go:1.18", + "features": { + "ghcr.io/devcontainers/features/sshd:1": {} }, - "settings": { - "go.toolsManagement.checkForUpdates": "local", - "go.useLanguageServer": true, - "go.gopath": "/go" + "remoteUser": "vscode", + "customizations": { + "vscode": { + "extensions": [ + "golang.go" + ], + "settings": { + "go.toolsManagement.checkForUpdates": "local", + "go.useLanguageServer": true, + "go.gopath": "/go" + } + } }, "runArgs": [ "--cap-add=SYS_PTRACE", "--security-opt", "seccomp=unconfined" - ], - "remoteUser": "vscode" + ] } diff --git a/.vscode/settings.json b/.vscode/settings.json deleted file mode 100644 index 99a52d6d6..000000000 --- a/.vscode/settings.json +++ /dev/null @@ -1,5 +0,0 @@ -{ - "search.exclude": { - "vendor/**": true - } -} \ No newline at end of file From 9e13f6ba6b17b1aec11d7e0971e5748e7541925f Mon Sep 17 00:00:00 2001 From: Jose Garcia Date: Tue, 11 Oct 2022 09:14:23 -0400 Subject: [PATCH 24/44] cleanup connect --- internal/codespaces/grpc/client.go | 35 +++++++++++++++--------------- 1 file changed, 17 insertions(+), 18 deletions(-) diff --git a/internal/codespaces/grpc/client.go b/internal/codespaces/grpc/client.go index 5ced382f8..76119d659 100644 --- a/internal/codespaces/grpc/client.go +++ b/internal/codespaces/grpc/client.go @@ -48,6 +48,12 @@ func Connect(ctx context.Context, session liveshareSession, token string) (*Clie if err != nil { return nil, fmt.Errorf("failed to listen to local port over tcp: %w", err) } + localAddress := fmt.Sprintf("127.0.0.1:%d", listener.Addr().(*net.TCPAddr).Port) + + client := &Client{ + token: token, + listener: listener, + } // Create a cancelable context to be able to cancel background tasks // if we encounter an error while connecting to the gRPC server @@ -58,29 +64,27 @@ func Connect(ctx context.Context, session liveshareSession, token string) (*Clie } }() + ch := make(chan error, 2) // Buffered channel to ensure we don't block on the goroutine + // Ensure we close the port forwarder if we encounter an error // or once the gRPC connection is closed. pfcancel is retained // to close the PF whenever we close the gRPC connection. pfctx, pfcancel := context.WithCancel(connectctx) - - ch := make(chan error, 2) // Buffered channel to ensure we don't block on the goroutine + client.cancelPF = pfcancel // Tunnel the remote gRPC server port to the local port - localAddress := fmt.Sprintf("127.0.0.1:%d", listener.Addr().(*net.TCPAddr).Port) go func() { fwd := liveshare.NewPortForwarder(session, codespacesInternalSessionName, codespacesInternalPort, true) ch <- fwd.ForwardToListener(pfctx, listener) }() - // Attempt to connect to the port - opts := []grpc.DialOption{ - grpc.WithTransportCredentials(insecure.NewCredentials()), - grpc.WithBlock(), - } - var conn *grpc.ClientConn - go func() { + // Attempt to connect to the port + opts := []grpc.DialOption{ + grpc.WithTransportCredentials(insecure.NewCredentials()), + grpc.WithBlock(), + } conn, err = grpc.DialContext(connectctx, localAddress, opts...) ch <- err // nil if we successfully connected }() @@ -95,15 +99,10 @@ func Connect(ctx context.Context, session liveshareSession, token string) (*Clie } } - g := &Client{ - conn: conn, - token: token, - listener: listener, - jupyterClient: jupyter.NewJupyterServerHostClient(conn), - cancelPF: pfcancel, - } + client.conn = conn + client.jupyterClient = jupyter.NewJupyterServerHostClient(conn) - return g, nil + return client, nil } // Closes the gRPC connection From a356a1bef0bfb32546aa775f3cf2cd79b9bf7e6c Mon Sep 17 00:00:00 2001 From: Jose Garcia Date: Tue, 11 Oct 2022 13:15:34 -0400 Subject: [PATCH 25/44] no need to ignore cancel --- pkg/cmd/codespace/jupyter.go | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/pkg/cmd/codespace/jupyter.go b/pkg/cmd/codespace/jupyter.go index 928dc8871..5c19c2ab1 100644 --- a/pkg/cmd/codespace/jupyter.go +++ b/pkg/cmd/codespace/jupyter.go @@ -89,7 +89,8 @@ func (a *App) Jupyter(ctx context.Context, codespaceName string) (err error) { } func connectToGRPCServer(ctx context.Context, session liveshareSession, token string) (*grpc.Client, error) { - ctx, _ = context.WithTimeout(ctx, grpc.ConnectionTimeout) + ctx, cancel := context.WithTimeout(ctx, grpc.ConnectionTimeout) + defer cancel() client, err := grpc.Connect(ctx, session, token) if err != nil { From 5962bc43833ad512cb5bbef7847c0b8dbfcd2989 Mon Sep 17 00:00:00 2001 From: Natthakit Susanthitanon Date: Wed, 12 Oct 2022 15:22:22 +0700 Subject: [PATCH 26/44] Add `ssh-key delete` command (#6273) --- pkg/cmd/ssh-key/delete/delete.go | 87 +++++++++++ pkg/cmd/ssh-key/delete/delete_test.go | 210 ++++++++++++++++++++++++++ pkg/cmd/ssh-key/delete/http.go | 66 ++++++++ pkg/cmd/ssh-key/ssh_key.go | 4 +- 4 files changed, 366 insertions(+), 1 deletion(-) create mode 100644 pkg/cmd/ssh-key/delete/delete.go create mode 100644 pkg/cmd/ssh-key/delete/delete_test.go create mode 100644 pkg/cmd/ssh-key/delete/http.go diff --git a/pkg/cmd/ssh-key/delete/delete.go b/pkg/cmd/ssh-key/delete/delete.go new file mode 100644 index 000000000..a11cbd769 --- /dev/null +++ b/pkg/cmd/ssh-key/delete/delete.go @@ -0,0 +1,87 @@ +package delete + +import ( + "fmt" + "net/http" + + "github.com/cli/cli/v2/internal/config" + "github.com/cli/cli/v2/internal/prompter" + "github.com/cli/cli/v2/pkg/cmdutil" + "github.com/cli/cli/v2/pkg/iostreams" + "github.com/spf13/cobra" +) + +type DeleteOptions struct { + IO *iostreams.IOStreams + Config func() (config.Config, error) + HttpClient func() (*http.Client, error) + + KeyID string + Confirmed bool + Prompter prompter.Prompter +} + +func NewCmdDelete(f *cmdutil.Factory, runF func(*DeleteOptions) error) *cobra.Command { + opts := &DeleteOptions{ + HttpClient: f.HttpClient, + Config: f.Config, + IO: f.IOStreams, + Prompter: f.Prompter, + } + + cmd := &cobra.Command{ + Use: "delete ", + Short: "Delete an SSH key from your GitHub account", + Args: cmdutil.ExactArgs(1, "cannot delete: key id required"), + RunE: func(cmd *cobra.Command, args []string) error { + opts.KeyID = args[0] + + if !opts.IO.CanPrompt() && !opts.Confirmed { + return cmdutil.FlagErrorf("--confirm required when not running interactively") + } + + if runF != nil { + return runF(opts) + } + return deleteRun(opts) + }, + } + + cmd.Flags().BoolVarP(&opts.Confirmed, "confirm", "y", false, "Skip the confirmation prompt") + return cmd +} + +func deleteRun(opts *DeleteOptions) error { + httpClient, err := opts.HttpClient() + if err != nil { + return err + } + + cfg, err := opts.Config() + if err != nil { + return err + } + + host, _ := cfg.DefaultHost() + key, err := getSSHKey(httpClient, host, opts.KeyID) + if err != nil { + return err + } + + if !opts.Confirmed { + if err := opts.Prompter.ConfirmDeletion(key.Title); err != nil { + return err + } + } + + err = deleteSSHKey(httpClient, host, opts.KeyID) + if err != nil { + return err + } + + if opts.IO.IsStdoutTTY() { + cs := opts.IO.ColorScheme() + fmt.Fprintf(opts.IO.Out, "%s SSH key %q (%s) deleted from your account\n", cs.SuccessIcon(), key.Title, opts.KeyID) + } + return nil +} diff --git a/pkg/cmd/ssh-key/delete/delete_test.go b/pkg/cmd/ssh-key/delete/delete_test.go new file mode 100644 index 000000000..437443c55 --- /dev/null +++ b/pkg/cmd/ssh-key/delete/delete_test.go @@ -0,0 +1,210 @@ +package delete + +import ( + "bytes" + "net/http" + "testing" + + "github.com/cli/cli/v2/internal/config" + "github.com/cli/cli/v2/internal/prompter" + "github.com/cli/cli/v2/pkg/cmdutil" + "github.com/cli/cli/v2/pkg/httpmock" + "github.com/cli/cli/v2/pkg/iostreams" + "github.com/google/shlex" + "github.com/stretchr/testify/assert" +) + +func TestNewCmdDelete(t *testing.T) { + tests := []struct { + name string + tty bool + input string + output DeleteOptions + wantErr bool + wantErrMsg string + }{ + { + name: "tty", + tty: true, + input: "123", + output: DeleteOptions{KeyID: "123", Confirmed: false}, + }, + { + name: "confirm flag tty", + tty: true, + input: "123 --confirm", + output: DeleteOptions{KeyID: "123", Confirmed: true}, + }, + { + name: "shorthand confirm flag tty", + tty: true, + input: "123 -y", + output: DeleteOptions{KeyID: "123", Confirmed: true}, + }, + { + name: "no tty", + input: "123", + wantErr: true, + wantErrMsg: "--confirm required when not running interactively", + }, + { + name: "confirm flag no tty", + input: "123 --confirm", + output: DeleteOptions{KeyID: "123", Confirmed: true}, + }, + { + name: "shorthand confirm flag no tty", + input: "123 -y", + output: DeleteOptions{KeyID: "123", Confirmed: true}, + }, + { + name: "no args", + input: "", + wantErr: true, + wantErrMsg: "cannot delete: key id required", + }, + { + name: "too many args", + input: "123 456", + wantErr: true, + wantErrMsg: "too many arguments", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + ios, _, _, _ := iostreams.Test() + ios.SetStdinTTY(tt.tty) + ios.SetStdoutTTY(tt.tty) + f := &cmdutil.Factory{ + IOStreams: ios, + } + argv, err := shlex.Split(tt.input) + assert.NoError(t, err) + + var cmdOpts *DeleteOptions + cmd := NewCmdDelete(f, func(opts *DeleteOptions) error { + cmdOpts = opts + return nil + }) + cmd.SetArgs(argv) + cmd.SetIn(&bytes.Buffer{}) + cmd.SetOut(&bytes.Buffer{}) + cmd.SetErr(&bytes.Buffer{}) + + _, err = cmd.ExecuteC() + if tt.wantErr { + assert.Error(t, err) + assert.EqualError(t, err, tt.wantErrMsg) + return + } + assert.NoError(t, err) + assert.Equal(t, tt.output.KeyID, cmdOpts.KeyID) + assert.Equal(t, tt.output.Confirmed, cmdOpts.Confirmed) + }) + } +} + +func Test_deleteRun(t *testing.T) { + keyResp := "{\"title\":\"My Key\"}" + tests := []struct { + name string + tty bool + opts DeleteOptions + httpStubs func(*httpmock.Registry) + prompterStubs func(*prompter.PrompterMock) + wantStdout string + wantErr bool + wantErrMsg string + }{ + { + name: "delete tty", + tty: true, + opts: DeleteOptions{KeyID: "123"}, + prompterStubs: func(pm *prompter.PrompterMock) { + pm.ConfirmDeletionFunc = func(_ string) error { + return nil + } + }, + httpStubs: func(reg *httpmock.Registry) { + reg.Register(httpmock.REST("GET", "user/keys/123"), httpmock.StatusStringResponse(200, keyResp)) + reg.Register(httpmock.REST("DELETE", "user/keys/123"), httpmock.StatusStringResponse(204, "")) + }, + wantStdout: "✓ SSH key \"My Key\" (123) deleted from your account\n", + }, + { + name: "delete with confirm flag tty", + tty: true, + opts: DeleteOptions{KeyID: "123", Confirmed: true}, + httpStubs: func(reg *httpmock.Registry) { + reg.Register(httpmock.REST("GET", "user/keys/123"), httpmock.StatusStringResponse(200, keyResp)) + reg.Register(httpmock.REST("DELETE", "user/keys/123"), httpmock.StatusStringResponse(204, "")) + }, + wantStdout: "✓ SSH key \"My Key\" (123) deleted from your account\n", + }, + { + name: "not found tty", + tty: true, + opts: DeleteOptions{KeyID: "123"}, + httpStubs: func(reg *httpmock.Registry) { + reg.Register(httpmock.REST("GET", "user/keys/123"), httpmock.StatusStringResponse(404, "")) + }, + wantErr: true, + wantErrMsg: "HTTP 404 (https://api.github.com/user/keys/123)", + }, + { + name: "delete no tty", + opts: DeleteOptions{KeyID: "123", Confirmed: true}, + httpStubs: func(reg *httpmock.Registry) { + reg.Register(httpmock.REST("GET", "user/keys/123"), httpmock.StatusStringResponse(200, keyResp)) + reg.Register(httpmock.REST("DELETE", "user/keys/123"), httpmock.StatusStringResponse(204, "")) + }, + wantStdout: "", + }, + { + name: "not found no tty", + opts: DeleteOptions{KeyID: "123", Confirmed: true}, + httpStubs: func(reg *httpmock.Registry) { + reg.Register(httpmock.REST("GET", "user/keys/123"), httpmock.StatusStringResponse(404, "")) + }, + wantErr: true, + wantErrMsg: "HTTP 404 (https://api.github.com/user/keys/123)", + }, + } + + for _, tt := range tests { + pm := &prompter.PrompterMock{} + if tt.prompterStubs != nil { + tt.prompterStubs(pm) + } + tt.opts.Prompter = pm + + reg := &httpmock.Registry{} + if tt.httpStubs != nil { + tt.httpStubs(reg) + } + + tt.opts.HttpClient = func() (*http.Client, error) { + return &http.Client{Transport: reg}, nil + } + tt.opts.Config = func() (config.Config, error) { + return config.NewBlankConfig(), nil + } + ios, _, stdout, _ := iostreams.Test() + ios.SetStdinTTY(tt.tty) + ios.SetStdoutTTY(tt.tty) + tt.opts.IO = ios + + t.Run(tt.name, func(t *testing.T) { + err := deleteRun(&tt.opts) + reg.Verify(t) + if tt.wantErr { + assert.Error(t, err) + assert.EqualError(t, err, tt.wantErrMsg) + return + } + assert.NoError(t, err) + assert.Equal(t, tt.wantStdout, stdout.String()) + }) + } +} diff --git a/pkg/cmd/ssh-key/delete/http.go b/pkg/cmd/ssh-key/delete/http.go new file mode 100644 index 000000000..906ae6bc9 --- /dev/null +++ b/pkg/cmd/ssh-key/delete/http.go @@ -0,0 +1,66 @@ +package delete + +import ( + "encoding/json" + "fmt" + "io" + "net/http" + + "github.com/cli/cli/v2/api" + "github.com/cli/cli/v2/internal/ghinstance" +) + +type sshKey struct { + Title string +} + +func deleteSSHKey(httpClient *http.Client, host string, keyID string) error { + url := fmt.Sprintf("%suser/keys/%s", ghinstance.RESTPrefix(host), keyID) + req, err := http.NewRequest("DELETE", url, nil) + if err != nil { + return err + } + + resp, err := httpClient.Do(req) + if err != nil { + return err + } + defer resp.Body.Close() + + if resp.StatusCode > 299 { + return api.HandleHTTPError(resp) + } + + return nil +} + +func getSSHKey(httpClient *http.Client, host string, keyID string) (*sshKey, error) { + url := fmt.Sprintf("%suser/keys/%s", ghinstance.RESTPrefix(host), keyID) + req, err := http.NewRequest("GET", url, nil) + if err != nil { + return nil, err + } + + resp, err := httpClient.Do(req) + if err != nil { + return nil, err + } + defer resp.Body.Close() + + if resp.StatusCode > 299 { + return nil, api.HandleHTTPError(resp) + } + + b, err := io.ReadAll(resp.Body) + if err != nil { + return nil, err + } + + var key sshKey + err = json.Unmarshal(b, &key) + if err != nil { + return nil, err + } + + return &key, nil +} diff --git a/pkg/cmd/ssh-key/ssh_key.go b/pkg/cmd/ssh-key/ssh_key.go index 312aeb779..809985737 100644 --- a/pkg/cmd/ssh-key/ssh_key.go +++ b/pkg/cmd/ssh-key/ssh_key.go @@ -2,6 +2,7 @@ package key import ( cmdAdd "github.com/cli/cli/v2/pkg/cmd/ssh-key/add" + cmdDelete "github.com/cli/cli/v2/pkg/cmd/ssh-key/delete" cmdList "github.com/cli/cli/v2/pkg/cmd/ssh-key/list" "github.com/cli/cli/v2/pkg/cmdutil" "github.com/spf13/cobra" @@ -14,8 +15,9 @@ func NewCmdSSHKey(f *cmdutil.Factory) *cobra.Command { Long: "Manage SSH keys registered with your GitHub account.", } - cmd.AddCommand(cmdList.NewCmdList(f, nil)) cmd.AddCommand(cmdAdd.NewCmdAdd(f, nil)) + cmd.AddCommand(cmdDelete.NewCmdDelete(f, nil)) + cmd.AddCommand(cmdList.NewCmdList(f, nil)) return cmd } From 4bd57ee0b2a5c43e2935e5c92a01463c9c1b2dc0 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Wed, 12 Oct 2022 18:13:10 +0300 Subject: [PATCH 27/44] Bump golang.org/x/text from 0.3.7 to 0.3.8 (#6431) Bumps [golang.org/x/text](https://github.com/golang/text) from 0.3.7 to 0.3.8. - [Release notes](https://github.com/golang/text/releases) - [Commits](https://github.com/golang/text/compare/v0.3.7...v0.3.8) --- updated-dependencies: - dependency-name: golang.org/x/text dependency-type: direct:production update-type: version-update:semver-patch ... Signed-off-by: dependabot[bot] Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- go.mod | 2 +- go.sum | 3 ++- 2 files changed, 3 insertions(+), 2 deletions(-) diff --git a/go.mod b/go.mod index 67d18003b..5b41c5898 100644 --- a/go.mod +++ b/go.mod @@ -37,7 +37,7 @@ require ( golang.org/x/sync v0.0.0-20210220032951-036812b2e83c golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab golang.org/x/term v0.0.0-20210927222741-03fcf44c2211 - golang.org/x/text v0.3.7 + golang.org/x/text v0.3.8 google.golang.org/grpc v1.49.0 google.golang.org/protobuf v1.27.1 gopkg.in/yaml.v3 v3.0.1 diff --git a/go.sum b/go.sum index 66d90b82e..eb57c19b6 100644 --- a/go.sum +++ b/go.sum @@ -385,8 +385,9 @@ golang.org/x/text v0.3.1-0.20180807135948-17ff2d5776d2/go.mod h1:NqM8EUOU14njkJ3 golang.org/x/text v0.3.2/go.mod h1:bEr9sfX3Q8Zfm5fL9x+3itogRgK3+ptLWKqgva+5dAk= golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= -golang.org/x/text v0.3.7 h1:olpwvP2KacW1ZWvsR7uQhoyTYvKAupfQrRGBFM352Gk= golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ= +golang.org/x/text v0.3.8 h1:nAL+RVCQ9uMn3vJZbV+MRnydTJFPf8qqY42YiA6MrqY= +golang.org/x/text v0.3.8/go.mod h1:E6s5w1FMmriuDzIBO73fBruAKo1PCIq6d2Q6DHfQ8WQ= golang.org/x/time v0.0.0-20181108054448-85acf8d2951c/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ= golang.org/x/time v0.0.0-20190308202827-9d24e82272b4/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ= golang.org/x/time v0.0.0-20191024005414-555d28b269f0/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ= From 21ded3e064ba8d1d3726357e65f676c320d35bfb Mon Sep 17 00:00:00 2001 From: David Gardiner Date: Wed, 12 Oct 2022 12:40:38 -0700 Subject: [PATCH 28/44] Skip flakey test in Actions --- internal/codespaces/grpc/client_test.go | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/internal/codespaces/grpc/client_test.go b/internal/codespaces/grpc/client_test.go index d905c0e29..c70e59ea6 100644 --- a/internal/codespaces/grpc/client_test.go +++ b/internal/codespaces/grpc/client_test.go @@ -4,6 +4,7 @@ import ( "context" "fmt" "log" + "os" "testing" grpctest "github.com/cli/cli/v2/internal/codespaces/grpc/test" @@ -11,6 +12,9 @@ import ( func startServer(t *testing.T) { t.Helper() + if os.Getenv("GITHUB_ACTIONS") == "true" { + t.Skip("fails intermittently in CI: https://github.com/cli/cli/issues/5663") + } ctx, cancel := context.WithCancel(context.Background()) From 4c3b123db67c3291d7aa9c8498231c1e32503383 Mon Sep 17 00:00:00 2001 From: Natthakit Susanthitanon Date: Fri, 14 Oct 2022 00:08:13 +0700 Subject: [PATCH 29/44] Return empty error before starting a pager program (#6419) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: Mislav Marohnić --- pkg/cmd/issue/list/list.go | 6 ++-- pkg/cmd/pr/checks/aggregate.go | 22 ++---------- pkg/cmd/pr/checks/checks.go | 60 +++++++++++++++------------------ pkg/cmd/pr/list/list.go | 6 ++-- pkg/cmd/run/list/list.go | 7 ++-- pkg/cmd/search/repos/repos.go | 6 ++-- pkg/cmd/search/shared/shared.go | 23 ++++++------- 7 files changed, 53 insertions(+), 77 deletions(-) diff --git a/pkg/cmd/issue/list/list.go b/pkg/cmd/issue/list/list.go index 24f2565f2..71ed5ed0f 100644 --- a/pkg/cmd/issue/list/list.go +++ b/pkg/cmd/issue/list/list.go @@ -185,6 +185,9 @@ func listRun(opts *ListOptions) error { if err != nil { return err } + if len(listResult.Issues) == 0 && opts.Exporter == nil { + return prShared.ListNoResults(ghrepo.FullName(baseRepo), "issue", !filterOptions.IsDefault()) + } if err := opts.IO.StartPager(); err == nil { defer opts.IO.StopPager() @@ -199,9 +202,6 @@ func listRun(opts *ListOptions) error { if listResult.SearchCapped { fmt.Fprintln(opts.IO.ErrOut, "warning: this query uses the Search API which is capped at 1000 results maximum") } - if len(listResult.Issues) == 0 { - return prShared.ListNoResults(ghrepo.FullName(baseRepo), "issue", !filterOptions.IsDefault()) - } if isTerminal { title := prShared.ListHeader(ghrepo.FullName(baseRepo), "issue", len(listResult.Issues), listResult.TotalCount, !filterOptions.IsDefault()) fmt.Fprintf(opts.IO.Out, "\n%s\n\n", title) diff --git a/pkg/cmd/pr/checks/aggregate.go b/pkg/cmd/pr/checks/aggregate.go index 2a01b31d8..53f2bab5f 100644 --- a/pkg/cmd/pr/checks/aggregate.go +++ b/pkg/cmd/pr/checks/aggregate.go @@ -24,20 +24,7 @@ type checkCounts struct { Skipping int } -func aggregateChecks(pr *api.PullRequest, requiredChecks bool) ([]check, checkCounts, error) { - checks := []check{} - counts := checkCounts{} - - if len(pr.StatusCheckRollup.Nodes) == 0 { - return checks, counts, fmt.Errorf("no commit found on the pull request") - } - - rollup := pr.StatusCheckRollup.Nodes[0].Commit.StatusCheckRollup.Contexts.Nodes - if len(rollup) == 0 { - return checks, counts, fmt.Errorf("no checks reported on the '%s' branch", pr.HeadRefName) - } - - checkContexts := pr.StatusCheckRollup.Nodes[0].Commit.StatusCheckRollup.Contexts.Nodes +func aggregateChecks(checkContexts []api.CheckContext, requiredChecks bool) (checks []check, counts checkCounts) { for _, c := range eliminateDuplicates(checkContexts) { if requiredChecks && !c.IsRequired { continue @@ -86,12 +73,7 @@ func aggregateChecks(pr *api.PullRequest, requiredChecks bool) ([]check, checkCo checks = append(checks, item) } - - if len(checks) == 0 && requiredChecks { - return checks, counts, fmt.Errorf("no required checks reported on the '%s' branch", pr.HeadRefName) - } - - return checks, counts, nil + return } // eliminateDuplicates filters a set of checks to only the most recent ones if the set includes repeated runs diff --git a/pkg/cmd/pr/checks/checks.go b/pkg/cmd/pr/checks/checks.go index 67c897a30..65df87b15 100644 --- a/pkg/cmd/pr/checks/checks.go +++ b/pkg/cmd/pr/checks/checks.go @@ -1,6 +1,7 @@ package checks import ( + "errors" "fmt" "net/http" "time" @@ -132,6 +133,15 @@ func checksRun(opts *ChecksOptions) error { return clientErr } + var checks []check + var counts checkCounts + var err error + + checks, counts, err = populateStatusChecks(client, repo, pr, opts.Required) + if err != nil { + return err + } + if opts.Watch { opts.IO.StartAlternateScreenBuffer() } else { @@ -143,23 +153,8 @@ func checksRun(opts *ChecksOptions) error { } } - var checks []check - var counts checkCounts - // Do not return err until we can StopAlternateScreenBuffer() - var err error - for { - err = populateStatusChecks(client, repo, pr) - if err != nil { - break - } - - checks, counts, err = aggregateChecks(pr, opts.Required) - if err != nil { - break - } - if counts.Pending != 0 && opts.Watch { opts.IO.RefreshScreen() cs := opts.IO.ColorScheme() @@ -177,6 +172,11 @@ func checksRun(opts *ChecksOptions) error { } time.Sleep(opts.Interval) + + checks, counts, err = populateStatusChecks(client, repo, pr, opts.Required) + if err != nil { + break + } } opts.IO.StopAlternateScreenBuffer() @@ -200,7 +200,7 @@ func checksRun(opts *ChecksOptions) error { return nil } -func populateStatusChecks(client *http.Client, repo ghrepo.Interface, pr *api.PullRequest) error { +func populateStatusChecks(client *http.Client, repo ghrepo.Interface, pr *api.PullRequest, requiredChecks bool) ([]check, checkCounts, error) { apiClient := api.NewClientFromHTTP(client) type response struct { @@ -208,7 +208,7 @@ func populateStatusChecks(client *http.Client, repo ghrepo.Interface, pr *api.Pu } query := fmt.Sprintf(` - query PullRequestStatusChecks($id: ID!, $endCursor: String!) { + query PullRequestStatusChecks($id: ID!, $endCursor: String) { node(id: $id) { ...on PullRequest { %s @@ -221,18 +221,16 @@ func populateStatusChecks(client *http.Client, repo ghrepo.Interface, pr *api.Pu } statusCheckRollup := api.CheckContexts{} - endCursor := "" for { - variables["endCursor"] = endCursor var resp response err := apiClient.GraphQL(repo.RepoHost(), query, variables, &resp) if err != nil { - return err + return nil, checkCounts{}, err } if len(resp.Node.StatusCheckRollup.Nodes) == 0 { - return nil + return nil, checkCounts{}, errors.New("no commit found on the pull request") } result := resp.Node.StatusCheckRollup.Nodes[0].Commit.StatusCheckRollup.Contexts @@ -244,18 +242,16 @@ func populateStatusChecks(client *http.Client, repo ghrepo.Interface, pr *api.Pu if !result.PageInfo.HasNextPage { break } - endCursor = result.PageInfo.EndCursor + variables["endCursor"] = result.PageInfo.EndCursor } - statusCheckRollup.PageInfo.HasNextPage = false + if len(statusCheckRollup.Nodes) == 0 { + return nil, checkCounts{}, fmt.Errorf("no checks reported on the '%s' branch", pr.HeadRefName) + } - pr.StatusCheckRollup.Nodes = []api.StatusCheckRollupNode{{ - Commit: api.StatusCheckRollupCommit{ - StatusCheckRollup: api.CommitStatusCheckRollup{ - Contexts: statusCheckRollup, - }, - }, - }} - - return nil + checks, counts := aggregateChecks(statusCheckRollup.Nodes, requiredChecks) + if len(checks) == 0 && requiredChecks { + return checks, counts, fmt.Errorf("no required checks reported on the '%s' branch", pr.HeadRefName) + } + return checks, counts, nil } diff --git a/pkg/cmd/pr/list/list.go b/pkg/cmd/pr/list/list.go index 6c1be1bef..ecf1c4454 100644 --- a/pkg/cmd/pr/list/list.go +++ b/pkg/cmd/pr/list/list.go @@ -175,6 +175,9 @@ func listRun(opts *ListOptions) error { if err != nil { return err } + if len(listResult.PullRequests) == 0 && opts.Exporter == nil { + return shared.ListNoResults(ghrepo.FullName(baseRepo), "pull request", !filters.IsDefault()) + } err = opts.IO.StartPager() if err != nil { @@ -189,9 +192,6 @@ func listRun(opts *ListOptions) error { if listResult.SearchCapped { fmt.Fprintln(opts.IO.ErrOut, "warning: this query uses the Search API which is capped at 1000 results maximum") } - if len(listResult.PullRequests) == 0 { - return shared.ListNoResults(ghrepo.FullName(baseRepo), "pull request", !filters.IsDefault()) - } if opts.IO.IsStdoutTTY() { title := shared.ListHeader(ghrepo.FullName(baseRepo), "pull request", len(listResult.PullRequests), listResult.TotalCount, !filters.IsDefault()) fmt.Fprintf(opts.IO.Out, "\n%s\n\n", title) diff --git a/pkg/cmd/run/list/list.go b/pkg/cmd/run/list/list.go index 2bd57ca92..c68245662 100644 --- a/pkg/cmd/run/list/list.go +++ b/pkg/cmd/run/list/list.go @@ -105,6 +105,9 @@ func listRun(opts *ListOptions) error { return fmt.Errorf("failed to get runs: %w", err) } runs := runsResult.WorkflowRuns + if len(runs) == 0 && opts.Exporter == nil { + return cmdutil.NewNoResultsError("no runs found") + } if err := opts.IO.StartPager(); err == nil { defer opts.IO.StopPager() @@ -116,10 +119,6 @@ func listRun(opts *ListOptions) error { return opts.Exporter.Write(opts.IO, runs) } - if len(runs) == 0 { - return cmdutil.NewNoResultsError("no runs found") - } - tp := utils.NewTablePrinter(opts.IO) cs := opts.IO.ColorScheme() diff --git a/pkg/cmd/search/repos/repos.go b/pkg/cmd/search/repos/repos.go index 23e5bfbef..8cfacf8fa 100644 --- a/pkg/cmd/search/repos/repos.go +++ b/pkg/cmd/search/repos/repos.go @@ -138,6 +138,9 @@ func reposRun(opts *ReposOptions) error { if err != nil { return err } + if len(result.Items) == 0 && opts.Exporter == nil { + return cmdutil.NewNoResultsError("no repositories matched your search") + } if err := io.StartPager(); err == nil { defer io.StopPager() } else { @@ -146,9 +149,6 @@ func reposRun(opts *ReposOptions) error { if opts.Exporter != nil { return opts.Exporter.Write(io, result.Items) } - if len(result.Items) == 0 { - return cmdutil.NewNoResultsError("no repositories matched your search") - } return displayResults(io, opts.Now, result) } diff --git a/pkg/cmd/search/shared/shared.go b/pkg/cmd/search/shared/shared.go index d095e8769..be163482b 100644 --- a/pkg/cmd/search/shared/shared.go +++ b/pkg/cmd/search/shared/shared.go @@ -65,18 +65,7 @@ func SearchIssues(opts *IssuesOptions) error { if err != nil { return err } - - if err := io.StartPager(); err == nil { - defer io.StopPager() - } else { - fmt.Fprintf(io.ErrOut, "failed to start pager: %v\n", err) - } - - if opts.Exporter != nil { - return opts.Exporter.Write(io, result.Items) - } - - if len(result.Items) == 0 { + if len(result.Items) == 0 && opts.Exporter == nil { var msg string switch opts.Entity { case Both: @@ -89,6 +78,16 @@ func SearchIssues(opts *IssuesOptions) error { return cmdutil.NewNoResultsError(msg) } + if err := io.StartPager(); err == nil { + defer io.StopPager() + } else { + fmt.Fprintf(io.ErrOut, "failed to start pager: %v\n", err) + } + + if opts.Exporter != nil { + return opts.Exporter.Write(io, result.Items) + } + return displayIssueResults(io, opts.Now, opts.Entity, result) } From 2944f7c3abac8db83f4354f0246ea32627e68d11 Mon Sep 17 00:00:00 2001 From: Sam Coe Date: Fri, 14 Oct 2022 10:47:03 +0300 Subject: [PATCH 30/44] Create git client (#6354) --- git/client.go | 611 ++++++++++++++++++++++++++ git/client_test.go | 365 +++++++++++++++ git/git.go | 452 ++++--------------- git/git_test.go | 213 --------- git/objects.go | 76 ++++ git/remote.go | 169 ------- git/remote_test.go | 35 -- pkg/cmd/auth/shared/git_credential.go | 9 +- pkg/cmd/factory/default.go | 13 + pkg/cmd/factory/default_test.go | 39 ++ pkg/cmd/pr/checkout/checkout.go | 29 +- pkg/cmd/release/create/create.go | 7 +- pkg/cmd/repo/create/create.go | 16 +- pkg/cmd/repo/create/create_test.go | 12 +- pkg/cmd/repo/fork/fork.go | 3 +- pkg/cmdutil/factory.go | 2 + 16 files changed, 1223 insertions(+), 828 deletions(-) create mode 100644 git/client.go create mode 100644 git/client_test.go delete mode 100644 git/git_test.go create mode 100644 git/objects.go delete mode 100644 git/remote.go delete mode 100644 git/remote_test.go diff --git a/git/client.go b/git/client.go new file mode 100644 index 000000000..7d8686f17 --- /dev/null +++ b/git/client.go @@ -0,0 +1,611 @@ +package git + +import ( + "bytes" + "context" + "errors" + "fmt" + "io" + "net/url" + "os/exec" + "path" + "regexp" + "runtime" + "sort" + "strings" + "sync" + + "github.com/cli/cli/v2/internal/run" + "github.com/cli/safeexec" +) + +var remoteRE = regexp.MustCompile(`(.+)\s+(.+)\s+\((push|fetch)\)`) + +// ErrNotOnAnyBranch indicates that the user is in detached HEAD state. +var ErrNotOnAnyBranch = errors.New("git: not on any branch") + +type NotInstalled struct { + message string + err error +} + +func (e *NotInstalled) Error() string { + return e.message +} + +func (e *NotInstalled) Unwrap() error { + return e.err +} + +type GitError struct { + stderr string + err error +} + +func (ge *GitError) Error() string { + stderr := ge.stderr + if stderr == "" { + var exitError *exec.ExitError + if errors.As(ge.err, &exitError) { + stderr = string(exitError.Stderr) + } + } + if stderr == "" { + return fmt.Sprintf("failed to run git: %v", ge.err) + } + return fmt.Sprintf("failed to run git: %s", stderr) +} + +func (ge *GitError) Unwrap() error { + return ge.err +} + +type gitCommand struct { + *exec.Cmd +} + +// This is a hack in order to not break the hundreds of +// existing tests that rely on `run.PrepareCmd` to be invoked. +func (gc *gitCommand) Run() error { + return run.PrepareCmd(gc.Cmd).Run() +} + +// This is a hack in order to not break the hundreds of +// existing tests that rely on `run.PrepareCmd` to be invoked. +func (gc *gitCommand) Output() ([]byte, error) { + return run.PrepareCmd(gc.Cmd).Output() +} + +type Client struct { + GhPath string + RepoDir string + GitPath string + Stderr io.Writer + Stdin io.Reader + Stdout io.Writer + + commandContext func(ctx context.Context, name string, args ...string) *exec.Cmd + mu sync.Mutex +} + +func (c *Client) Command(ctx context.Context, args ...string) (*gitCommand, error) { + if c.RepoDir != "" { + args = append([]string{"-C", c.RepoDir}, args...) + } + commandContext := exec.CommandContext + if c.commandContext != nil { + commandContext = c.commandContext + } + var err error + c.mu.Lock() + if c.GitPath == "" { + c.GitPath, err = resolveGitPath() + } + c.mu.Unlock() + if err != nil { + return nil, err + } + cmd := commandContext(ctx, c.GitPath, args...) + cmd.Stderr = c.Stderr + cmd.Stdin = c.Stdin + cmd.Stdout = c.Stdout + return &gitCommand{cmd}, nil +} + +func resolveGitPath() (string, error) { + path, err := safeexec.LookPath("git") + if err != nil { + if errors.Is(err, exec.ErrNotFound) { + programName := "git" + if runtime.GOOS == "windows" { + programName = "Git for Windows" + } + return "", &NotInstalled{ + message: fmt.Sprintf("unable to find git executable in PATH; please install %s before retrying", programName), + err: err, + } + } + return "", err + } + return path, nil +} + +// AuthenticatedCommand is a wrapper around Command that included configuration to use gh +// as the credential helper for git. +func (c *Client) AuthenticatedCommand(ctx context.Context, args ...string) (*gitCommand, error) { + preArgs := []string{} + preArgs = append(preArgs, "-c", "credential.helper=") + if c.GhPath == "" { + // Assumes that gh is in PATH. + c.GhPath = "gh" + } + credHelper := fmt.Sprintf("!%q auth git-credential", c.GhPath) + preArgs = append(preArgs, "-c", fmt.Sprintf("credential.helper=%s", credHelper)) + args = append(preArgs, args...) + return c.Command(ctx, args...) +} + +func (c *Client) Remotes(ctx context.Context) (RemoteSet, error) { + remoteArgs := []string{"remote", "-v"} + remoteCmd, err := c.Command(ctx, remoteArgs...) + if err != nil { + return nil, err + } + remoteOut, remoteErr := remoteCmd.Output() + if remoteErr != nil { + return nil, &GitError{err: remoteErr} + } + + configArgs := []string{"config", "--get-regexp", `^remote\..*\.gh-resolved$`} + configCmd, err := c.Command(ctx, configArgs...) + if err != nil { + return nil, err + } + configOut, configErr := configCmd.Output() + if configErr != nil { + return nil, &GitError{err: configErr} + } + + remotes := parseRemotes(outputLines(remoteOut)) + populateResolvedRemotes(remotes, outputLines(configOut)) + sort.Sort(remotes) + return remotes, nil +} + +func (c *Client) AddRemote(ctx context.Context, name, urlStr string, trackingBranches []string) (*Remote, error) { + args := []string{"remote", "add"} + for _, branch := range trackingBranches { + args = append(args, "-t", branch) + } + args = append(args, "-f", name, urlStr) + //TODO: Use AuthenticatedCommand + cmd, err := c.Command(ctx, args...) + if err != nil { + return nil, err + } + if err := cmd.Run(); err != nil { + return nil, err + } + var urlParsed *url.URL + if strings.HasPrefix(urlStr, "https") { + urlParsed, err = url.Parse(urlStr) + if err != nil { + return nil, err + } + } else { + urlParsed, err = ParseURL(urlStr) + if err != nil { + return nil, err + } + } + remote := &Remote{ + Name: name, + FetchURL: urlParsed, + PushURL: urlParsed, + } + return remote, nil +} + +func (c *Client) UpdateRemoteURL(ctx context.Context, name, url string) error { + args := []string{"remote", "set-url", name, url} + cmd, err := c.Command(ctx, args...) + if err != nil { + return err + } + return cmd.Run() +} + +func (c *Client) SetRemoteResolution(ctx context.Context, name, resolution string) error { + args := []string{"config", "--add", fmt.Sprintf("remote.%s.gh-resolved", name), resolution} + cmd, err := c.Command(ctx, args...) + if err != nil { + return err + } + return cmd.Run() +} + +// CurrentBranch reads the checked-out branch for the git repository. +func (c *Client) CurrentBranch(ctx context.Context) (string, error) { + args := []string{"symbolic-ref", "--quiet", "HEAD"} + cmd, err := c.Command(ctx, args...) + if err != nil { + return "", err + } + errBuf := bytes.Buffer{} + cmd.Stderr = &errBuf + out, err := cmd.Output() + if err != nil { + if errBuf.Len() == 0 { + return "", &GitError{err: err, stderr: "not on any branch"} + } + return "", &GitError{err: err, stderr: errBuf.String()} + } + branch := firstLine(out) + return strings.TrimPrefix(branch, "refs/heads/"), nil +} + +// ShowRefs resolves fully-qualified refs to commit hashes. +func (c *Client) ShowRefs(ctx context.Context, ref ...string) ([]Ref, error) { + args := append([]string{"show-ref", "--verify", "--"}, ref...) + cmd, err := c.Command(ctx, args...) + if err != nil { + return nil, err + } + out, err := cmd.Output() + if err != nil { + return nil, &GitError{err: err} + } + var refs []Ref + for _, line := range outputLines(out) { + parts := strings.SplitN(line, " ", 2) + if len(parts) < 2 { + continue + } + refs = append(refs, Ref{ + Hash: parts[0], + Name: parts[1], + }) + } + return refs, nil +} + +func (c *Client) Config(ctx context.Context, name string) (string, error) { + args := []string{"config", name} + cmd, err := c.Command(ctx, args...) + if err != nil { + return "", err + } + errBuf := bytes.Buffer{} + cmd.Stderr = &errBuf + out, err := cmd.Output() + if err != nil { + var exitError *exec.ExitError + if ok := errors.As(err, &exitError); ok && exitError.Error() == "1" { + return "", &GitError{err: err, stderr: fmt.Sprintf("unknown config key %s", name)} + } + return "", &GitError{err: err, stderr: errBuf.String()} + } + return firstLine(out), nil +} + +func (c *Client) UncommittedChangeCount(ctx context.Context) (int, error) { + args := []string{"status", "--porcelain"} + cmd, err := c.Command(ctx, args...) + if err != nil { + return 0, err + } + out, err := cmd.Output() + if err != nil { + return 0, &GitError{err: err} + } + lines := strings.Split(string(out), "\n") + count := 0 + for _, l := range lines { + if l != "" { + count++ + } + } + return count, nil +} + +func (c *Client) Commits(ctx context.Context, baseRef, headRef string) ([]*Commit, error) { + args := []string{"-c", "log.ShowSignature=false", "log", "--pretty=format:%H,%s", "--cherry", fmt.Sprintf("%s...%s", baseRef, headRef)} + cmd, err := c.Command(ctx, args...) + if err != nil { + return nil, err + } + out, err := cmd.Output() + if err != nil { + return nil, &GitError{err: err} + } + commits := []*Commit{} + sha := 0 + title := 1 + for _, line := range outputLines(out) { + split := strings.SplitN(line, ",", 2) + if len(split) != 2 { + continue + } + commits = append(commits, &Commit{ + Sha: split[sha], + Title: split[title], + }) + } + if len(commits) == 0 { + return nil, fmt.Errorf("could not find any commits between %s and %s", baseRef, headRef) + } + return commits, nil +} + +func (c *Client) lookupCommit(ctx context.Context, sha, format string) ([]byte, error) { + args := []string{"-c", "log.ShowSignature=false", "show", "-s", "--pretty=format:" + format, sha} + cmd, err := c.Command(ctx, args...) + if err != nil { + return nil, err + } + out, err := cmd.Output() + if err != nil { + return nil, &GitError{err: err} + } + return out, nil +} + +func (c *Client) LastCommit(ctx context.Context) (*Commit, error) { + output, err := c.lookupCommit(ctx, "HEAD", "%H,%s") + if err != nil { + return nil, err + } + idx := bytes.IndexByte(output, ',') + return &Commit{ + Sha: string(output[0:idx]), + Title: strings.TrimSpace(string(output[idx+1:])), + }, nil +} + +func (c *Client) CommitBody(ctx context.Context, sha string) (string, error) { + output, err := c.lookupCommit(ctx, sha, "%b") + return string(output), err +} + +// Push publishes a git ref to a remote and sets up upstream configuration. +func (c *Client) Push(ctx context.Context, remote string, ref string) error { + args := []string{"push", "--set-upstream", remote, ref} + //TODO: Use AuthenticatedCommand + cmd, err := c.Command(ctx, args...) + if err != nil { + return err + } + return cmd.Run() +} + +// ReadBranchConfig parses the `branch.BRANCH.(remote|merge)` part of git config. +func (c *Client) ReadBranchConfig(ctx context.Context, branch string) (cfg BranchConfig) { + prefix := regexp.QuoteMeta(fmt.Sprintf("branch.%s.", branch)) + args := []string{"config", "--get-regexp", fmt.Sprintf("^%s(remote|merge)$", prefix)} + cmd, err := c.Command(ctx, args...) + if err != nil { + return + } + out, err := cmd.Output() + if err != nil { + return + } + for _, line := range outputLines(out) { + parts := strings.SplitN(line, " ", 2) + if len(parts) < 2 { + continue + } + keys := strings.Split(parts[0], ".") + switch keys[len(keys)-1] { + case "remote": + if strings.Contains(parts[1], ":") { + u, err := ParseURL(parts[1]) + if err != nil { + continue + } + cfg.RemoteURL = u + } else if !isFilesystemPath(parts[1]) { + cfg.RemoteName = parts[1] + } + case "merge": + cfg.MergeRef = parts[1] + } + } + return +} + +func (c *Client) DeleteLocalBranch(ctx context.Context, branch string) error { + args := []string{"branch", "-D", branch} + cmd, err := c.Command(ctx, args...) + if err != nil { + return err + } + return cmd.Run() +} + +func (c *Client) HasLocalBranch(ctx context.Context, branch string) bool { + args := []string{"rev-parse", "--verify", "refs/heads/" + branch} + cmd, err := c.Command(ctx, args...) + if err != nil { + return false + } + err = cmd.Run() + return err == nil +} + +func (c *Client) CheckoutBranch(ctx context.Context, branch string) error { + args := []string{"checkout", branch} + cmd, err := c.Command(ctx, args...) + if err != nil { + return err + } + return cmd.Run() +} + +func (c *Client) CheckoutNewBranch(ctx context.Context, remoteName, branch string) error { + track := fmt.Sprintf("%s/%s", remoteName, branch) + args := []string{"checkout", "-b", branch, "--track", track} + cmd, err := c.Command(ctx, args...) + if err != nil { + return err + } + return cmd.Run() +} + +func (c *Client) Pull(ctx context.Context, remote, branch string) error { + args := []string{"pull", "--ff-only", remote, branch} + //TODO: Use AuthenticatedCommand + cmd, err := c.Command(ctx, args...) + if err != nil { + return err + } + return cmd.Run() +} + +func (c *Client) Clone(ctx context.Context, cloneURL string, args []string) (target string, err error) { + cloneArgs, target := parseCloneArgs(args) + cloneArgs = append(cloneArgs, cloneURL) + // If the args contain an explicit target, pass it to clone + // otherwise, parse the URL to determine where git cloned it to so we can return it + if target != "" { + cloneArgs = append(cloneArgs, target) + } else { + target = path.Base(strings.TrimSuffix(cloneURL, ".git")) + } + cloneArgs = append([]string{"clone"}, cloneArgs...) + //TODO: Use AuthenticatedCommand + cmd, err := c.Command(ctx, cloneArgs...) + if err != nil { + return "", err + } + err = cmd.Run() + return +} + +// ToplevelDir returns the top-level directory path of the current repository. +func (c *Client) ToplevelDir(ctx context.Context) (string, error) { + args := []string{"rev-parse", "--show-toplevel"} + cmd, err := c.Command(ctx, args...) + if err != nil { + return "", err + } + out, err := cmd.Output() + if err != nil { + return "", &GitError{err: err} + } + return firstLine(out), nil +} + +func (c *Client) GitDir(ctx context.Context) (string, error) { + args := []string{"rev-parse", "--git-dir"} + cmd, err := c.Command(ctx, args...) + if err != nil { + return "", err + } + out, err := cmd.Output() + if err != nil { + return "", &GitError{err: err} + } + return firstLine(out), nil +} + +// Show current directory relative to the top-level directory of repository. +func (c *Client) PathFromRoot(ctx context.Context) string { + args := []string{"rev-parse", "--show-prefix"} + cmd, err := c.Command(ctx, args...) + if err != nil { + return "" + } + out, err := cmd.Output() + if err != nil { + return "" + } + if path := firstLine(out); path != "" { + return path[:len(path)-1] + } + return "" +} + +func isFilesystemPath(p string) bool { + return p == "." || strings.HasPrefix(p, "./") || strings.HasPrefix(p, "/") +} + +func outputLines(output []byte) []string { + lines := strings.TrimSuffix(string(output), "\n") + return strings.Split(lines, "\n") +} + +func firstLine(output []byte) string { + if i := bytes.IndexAny(output, "\n"); i >= 0 { + return string(output)[0:i] + } + return string(output) +} + +func parseCloneArgs(extraArgs []string) (args []string, target string) { + args = extraArgs + if len(args) > 0 { + if !strings.HasPrefix(args[0], "-") { + target, args = args[0], args[1:] + } + } + return +} + +func parseRemotes(remotesStr []string) RemoteSet { + remotes := RemoteSet{} + for _, r := range remotesStr { + match := remoteRE.FindStringSubmatch(r) + if match == nil { + continue + } + name := strings.TrimSpace(match[1]) + urlStr := strings.TrimSpace(match[2]) + urlType := strings.TrimSpace(match[3]) + + url, err := ParseURL(urlStr) + if err != nil { + continue + } + + var rem *Remote + if len(remotes) > 0 { + rem = remotes[len(remotes)-1] + if name != rem.Name { + rem = nil + } + } + if rem == nil { + rem = &Remote{Name: name} + remotes = append(remotes, rem) + } + + switch urlType { + case "fetch": + rem.FetchURL = url + case "push": + rem.PushURL = url + } + } + return remotes +} + +func populateResolvedRemotes(remotes RemoteSet, resolved []string) { + for _, l := range resolved { + parts := strings.SplitN(l, " ", 2) + if len(parts) < 2 { + continue + } + rp := strings.SplitN(parts[0], ".", 3) + if len(rp) < 2 { + continue + } + name := rp[1] + for _, r := range remotes { + if r.Name == name { + r.Resolved = parts[1] + break + } + } + } +} diff --git a/git/client_test.go b/git/client_test.go new file mode 100644 index 000000000..d8cff126b --- /dev/null +++ b/git/client_test.go @@ -0,0 +1,365 @@ +package git + +import ( + "bytes" + "context" + "os" + "path/filepath" + "testing" + + "github.com/cli/cli/v2/internal/run" + "github.com/stretchr/testify/assert" +) + +func TestClientCommand(t *testing.T) { + tests := []struct { + name string + repoDir string + gitPath string + wantExe string + wantArgs []string + }{ + { + name: "creates command", + gitPath: "path/to/git", + wantExe: "path/to/git", + wantArgs: []string{"path/to/git", "ref-log"}, + }, + { + name: "adds repo directory configuration", + repoDir: "path/to/repo", + gitPath: "path/to/git", + wantExe: "path/to/git", + wantArgs: []string{"path/to/git", "-C", "path/to/repo", "ref-log"}, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + in, out, errOut := &bytes.Buffer{}, &bytes.Buffer{}, &bytes.Buffer{} + client := Client{ + Stdin: in, + Stdout: out, + Stderr: errOut, + RepoDir: tt.repoDir, + GitPath: tt.gitPath, + } + cmd, err := client.Command(context.Background(), "ref-log") + assert.NoError(t, err) + assert.Equal(t, tt.wantExe, cmd.Path) + assert.Equal(t, tt.wantArgs, cmd.Args) + assert.Equal(t, in, cmd.Stdin) + assert.Equal(t, out, cmd.Stdout) + assert.Equal(t, errOut, cmd.Stderr) + }) + } +} + +func TestClientAuthenticatedCommand(t *testing.T) { + tests := []struct { + name string + path string + wantArgs []string + }{ + { + name: "adds credential helper config options", + path: "path/to/gh", + wantArgs: []string{"git", "-c", "credential.helper=", "-c", "credential.helper=!\"path/to/gh\" auth git-credential", "fetch"}, + }, + { + name: "fallback when GhPath is not set", + wantArgs: []string{"git", "-c", "credential.helper=", "-c", "credential.helper=!\"gh\" auth git-credential", "fetch"}, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + client := Client{ + GhPath: tt.path, + GitPath: "git", + } + cmd, err := client.AuthenticatedCommand(context.Background(), "fetch") + assert.NoError(t, err) + assert.Equal(t, tt.wantArgs, cmd.Args) + }) + } +} + +func TestClientRemotes(t *testing.T) { + tempDir := t.TempDir() + initRepo(t, tempDir) + gitDir := filepath.Join(tempDir, ".git") + remoteFile := filepath.Join(gitDir, "config") + remotes := ` +[remote "origin"] + url = git@example.com:monalisa/origin.git +[remote "test"] + url = git://github.com/hubot/test.git + gh-resolved = other +[remote "upstream"] + url = https://github.com/monalisa/upstream.git + gh-resolved = base +[remote "github"] + url = git@github.com:hubot/github.git +` + f, err := os.OpenFile(remoteFile, os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0755) + assert.NoError(t, err) + _, err = f.Write([]byte(remotes)) + assert.NoError(t, err) + err = f.Close() + assert.NoError(t, err) + client := Client{ + RepoDir: tempDir, + } + rs, err := client.Remotes(context.Background()) + assert.NoError(t, err) + assert.Equal(t, 4, len(rs)) + assert.Equal(t, "upstream", rs[0].Name) + assert.Equal(t, "base", rs[0].Resolved) + assert.Equal(t, "github", rs[1].Name) + assert.Equal(t, "", rs[1].Resolved) + assert.Equal(t, "origin", rs[2].Name) + assert.Equal(t, "", rs[2].Resolved) + assert.Equal(t, "test", rs[3].Name) + assert.Equal(t, "other", rs[3].Resolved) +} + +func TestParseRemotes(t *testing.T) { + remoteList := []string{ + "mona\tgit@github.com:monalisa/myfork.git (fetch)", + "origin\thttps://github.com/monalisa/octo-cat.git (fetch)", + "origin\thttps://github.com/monalisa/octo-cat-push.git (push)", + "upstream\thttps://example.com/nowhere.git (fetch)", + "upstream\thttps://github.com/hubot/tools (push)", + "zardoz\thttps://example.com/zed.git (push)", + "koke\tgit://github.com/koke/grit.git (fetch)", + "koke\tgit://github.com/koke/grit.git (push)", + } + + r := parseRemotes(remoteList) + assert.Equal(t, 5, len(r)) + + assert.Equal(t, "mona", r[0].Name) + assert.Equal(t, "ssh://git@github.com/monalisa/myfork.git", r[0].FetchURL.String()) + assert.Nil(t, r[0].PushURL) + + assert.Equal(t, "origin", r[1].Name) + assert.Equal(t, "/monalisa/octo-cat.git", r[1].FetchURL.Path) + assert.Equal(t, "/monalisa/octo-cat-push.git", r[1].PushURL.Path) + + assert.Equal(t, "upstream", r[2].Name) + assert.Equal(t, "example.com", r[2].FetchURL.Host) + assert.Equal(t, "github.com", r[2].PushURL.Host) + + assert.Equal(t, "zardoz", r[3].Name) + assert.Nil(t, r[3].FetchURL) + assert.Equal(t, "https://example.com/zed.git", r[3].PushURL.String()) + + assert.Equal(t, "koke", r[4].Name) + assert.Equal(t, "/koke/grit.git", r[4].FetchURL.Path) + assert.Equal(t, "/koke/grit.git", r[4].PushURL.Path) +} + +func TestClientLastCommit(t *testing.T) { + client := Client{ + RepoDir: "./fixtures/simple.git", + } + c, err := client.LastCommit(context.Background()) + assert.NoError(t, err) + assert.Equal(t, "6f1a2405cace1633d89a79c74c65f22fe78f9659", c.Sha) + assert.Equal(t, "Second commit", c.Title) +} + +func TestClientCommitBody(t *testing.T) { + client := Client{ + RepoDir: "./fixtures/simple.git", + } + body, err := client.CommitBody(context.Background(), "6f1a2405cace1633d89a79c74c65f22fe78f9659") + assert.NoError(t, err) + assert.Equal(t, "I'm starting to get the hang of things\n", body) +} + +func TestClientUncommittedChangeCount(t *testing.T) { + tests := []struct { + name string + expected int + output string + }{ + { + name: "no changes", + expected: 0, + output: "", + }, + { + name: "one change", + expected: 1, + output: " M poem.txt", + }, + { + name: "untracked file", + expected: 2, + output: " M poem.txt\n?? new.txt", + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + cs, restore := run.Stub() + defer restore(t) + cs.Register(`git status --porcelain`, 0, tt.output) + client := Client{} + ucc, err := client.UncommittedChangeCount(context.Background()) + assert.NoError(t, err) + assert.Equal(t, tt.expected, ucc) + }) + } +} + +func TestClientCurrentBranch(t *testing.T) { + tests := []struct { + name string + stub string + expected string + }{ + { + name: "branch name", + stub: "branch-name\n", + expected: "branch-name", + }, + { + name: "ref", + stub: "refs/heads/branch-name\n", + expected: "branch-name", + }, + { + name: "escaped ref", + stub: "refs/heads/branch\u00A0with\u00A0non\u00A0breaking\u00A0space\n", + expected: "branch\u00A0with\u00A0non\u00A0breaking\u00A0space", + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + cs, teardown := run.Stub() + defer teardown(t) + cs.Register(`git symbolic-ref --quiet HEAD`, 0, tt.stub) + client := Client{} + branch, err := client.CurrentBranch(context.Background()) + assert.NoError(t, err) + assert.Equal(t, tt.expected, branch) + }) + } +} + +func TestClientCurrentBranch_detached_head(t *testing.T) { + cs, teardown := run.Stub() + defer teardown(t) + cs.Register(`git symbolic-ref --quiet HEAD`, 1, "") + client := Client{} + _, err := client.CurrentBranch(context.Background()) + assert.EqualError(t, err, "failed to run git: not on any branch") +} + +func TestParseCloneArgs(t *testing.T) { + type wanted struct { + args []string + dir string + } + tests := []struct { + name string + args []string + want wanted + }{ + { + name: "args and target", + args: []string{"target_directory", "-o", "upstream", "--depth", "1"}, + want: wanted{ + args: []string{"-o", "upstream", "--depth", "1"}, + dir: "target_directory", + }, + }, + { + name: "only args", + args: []string{"-o", "upstream", "--depth", "1"}, + want: wanted{ + args: []string{"-o", "upstream", "--depth", "1"}, + dir: "", + }, + }, + { + name: "only target", + args: []string{"target_directory"}, + want: wanted{ + args: []string{}, + dir: "target_directory", + }, + }, + { + name: "no args", + args: []string{}, + want: wanted{ + args: []string{}, + dir: "", + }, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + args, dir := parseCloneArgs(tt.args) + got := wanted{args: args, dir: dir} + assert.Equal(t, got, tt.want) + }) + } +} + +func TestClientAddRemote(t *testing.T) { + tests := []struct { + title string + name string + url string + dir string + branches []string + want string + }{ + { + title: "fetch all", + name: "test", + url: "URL", + dir: "DIRECTORY", + branches: []string{}, + want: "git -C DIRECTORY remote add -f test URL", + }, + { + title: "fetch specific branches only", + name: "test", + url: "URL", + dir: "DIRECTORY", + branches: []string{"trunk", "dev"}, + want: "git -C DIRECTORY remote add -t trunk -t dev -f test URL", + }, + } + for _, tt := range tests { + t.Run(tt.title, func(t *testing.T) { + cs, cmdTeardown := run.Stub() + defer cmdTeardown(t) + cs.Register(tt.want, 0, "") + client := Client{ + RepoDir: tt.dir, + } + _, err := client.AddRemote(context.Background(), tt.name, tt.url, tt.branches) + assert.NoError(t, err) + }) + } +} + +func initRepo(t *testing.T, dir string) { + errBuf := &bytes.Buffer{} + inBuf := &bytes.Buffer{} + outBuf := &bytes.Buffer{} + client := Client{ + RepoDir: dir, + Stderr: errBuf, + Stdin: inBuf, + Stdout: outBuf, + } + cmd, err := client.Command(context.Background(), []string{"init", "--quiet"}...) + assert.NoError(t, err) + err = cmd.Run() + assert.NoError(t, err) +} diff --git a/git/git.go b/git/git.go index defeae713..5b934175b 100644 --- a/git/git.go +++ b/git/git.go @@ -1,442 +1,154 @@ package git import ( - "bytes" - "errors" - "fmt" + "context" "io" - "net/url" "os" - "os/exec" - "path" - "regexp" - "runtime" - "strings" - - "github.com/cli/cli/v2/internal/run" - "github.com/cli/safeexec" ) -// ErrNotOnAnyBranch indicates that the user is in detached HEAD state -var ErrNotOnAnyBranch = errors.New("git: not on any branch") - -// Ref represents a git commit reference -type Ref struct { - Hash string - Name string +func GitCommand(args ...string) (*gitCommand, error) { + c := &Client{} + return c.Command(context.Background(), args...) } -// TrackingRef represents a ref for a remote tracking branch -type TrackingRef struct { - RemoteName string - BranchName string -} - -func (r TrackingRef) String() string { - return "refs/remotes/" + r.RemoteName + "/" + r.BranchName -} - -// ShowRefs resolves fully-qualified refs to commit hashes func ShowRefs(ref ...string) ([]Ref, error) { - args := append([]string{"show-ref", "--verify", "--"}, ref...) - showRef, err := GitCommand(args...) - if err != nil { - return nil, err - } - output, err := run.PrepareCmd(showRef).Output() - - var refs []Ref - for _, line := range outputLines(output) { - parts := strings.SplitN(line, " ", 2) - if len(parts) < 2 { - continue - } - refs = append(refs, Ref{ - Hash: parts[0], - Name: parts[1], - }) - } - - return refs, err + c := &Client{} + return c.ShowRefs(context.Background(), ref...) } -// CurrentBranch reads the checked-out branch for the git repository func CurrentBranch() (string, error) { - refCmd, err := GitCommand("symbolic-ref", "--quiet", "HEAD") - if err != nil { - return "", err - } - - stderr := bytes.Buffer{} - refCmd.Stderr = &stderr - - output, err := run.PrepareCmd(refCmd).Output() - if err == nil { - // Found the branch name - return getBranchShortName(output), nil - } - - if stderr.Len() == 0 { - // Detached head - return "", ErrNotOnAnyBranch - } - - return "", fmt.Errorf("%sgit: %s", stderr.String(), err) -} - -func listRemotesForPath(path string) ([]string, error) { - remoteCmd, err := GitCommand("-C", path, "remote", "-v") - if err != nil { - return nil, err - } - output, err := run.PrepareCmd(remoteCmd).Output() - return outputLines(output), err -} - -func listRemotes() ([]string, error) { - remoteCmd, err := GitCommand("remote", "-v") - if err != nil { - return nil, err - } - output, err := run.PrepareCmd(remoteCmd).Output() - return outputLines(output), err + c := &Client{} + return c.CurrentBranch(context.Background()) } func Config(name string) (string, error) { - configCmd, err := GitCommand("config", name) - if err != nil { - return "", err - } - output, err := run.PrepareCmd(configCmd).Output() - if err != nil { - return "", fmt.Errorf("unknown config key: %s", name) - } - - return firstLine(output), nil - -} - -type NotInstalled struct { - message string - error -} - -func (e *NotInstalled) Error() string { - return e.message -} - -func GitCommand(args ...string) (*exec.Cmd, error) { - gitExe, err := safeexec.LookPath("git") - if err != nil { - if errors.Is(err, exec.ErrNotFound) { - programName := "git" - if runtime.GOOS == "windows" { - programName = "Git for Windows" - } - return nil, &NotInstalled{ - message: fmt.Sprintf("unable to find git executable in PATH; please install %s before retrying", programName), - error: err, - } - } - return nil, err - } - return exec.Command(gitExe, args...), nil + c := &Client{} + return c.Config(context.Background(), name) } func UncommittedChangeCount() (int, error) { - statusCmd, err := GitCommand("status", "--porcelain") - if err != nil { - return 0, err - } - output, err := run.PrepareCmd(statusCmd).Output() - if err != nil { - return 0, err - } - lines := strings.Split(string(output), "\n") - - count := 0 - - for _, l := range lines { - if l != "" { - count++ - } - } - - return count, nil -} - -type Commit struct { - Sha string - Title string + c := &Client{} + return c.UncommittedChangeCount(context.Background()) } func Commits(baseRef, headRef string) ([]*Commit, error) { - logCmd, err := GitCommand( - "-c", "log.ShowSignature=false", - "log", "--pretty=format:%H,%s", - "--cherry", fmt.Sprintf("%s...%s", baseRef, headRef)) - if err != nil { - return nil, err - } - output, err := run.PrepareCmd(logCmd).Output() - if err != nil { - return []*Commit{}, err - } - - commits := []*Commit{} - sha := 0 - title := 1 - for _, line := range outputLines(output) { - split := strings.SplitN(line, ",", 2) - if len(split) != 2 { - continue - } - commits = append(commits, &Commit{ - Sha: split[sha], - Title: split[title], - }) - } - - if len(commits) == 0 { - return commits, fmt.Errorf("could not find any commits between %s and %s", baseRef, headRef) - } - - return commits, nil -} - -func lookupCommit(sha, format string) ([]byte, error) { - logCmd, err := GitCommand("-c", "log.ShowSignature=false", "show", "-s", "--pretty=format:"+format, sha) - if err != nil { - return nil, err - } - return run.PrepareCmd(logCmd).Output() + c := &Client{} + return c.Commits(context.Background(), baseRef, headRef) } func LastCommit() (*Commit, error) { - output, err := lookupCommit("HEAD", "%H,%s") - if err != nil { - return nil, err - } - - idx := bytes.IndexByte(output, ',') - return &Commit{ - Sha: string(output[0:idx]), - Title: strings.TrimSpace(string(output[idx+1:])), - }, nil + c := &Client{} + return c.LastCommit(context.Background()) } func CommitBody(sha string) (string, error) { - output, err := lookupCommit(sha, "%b") - return string(output), err + c := &Client{} + return c.CommitBody(context.Background(), sha) } -// Push publishes a git ref to a remote and sets up upstream configuration func Push(remote string, ref string, cmdIn io.ReadCloser, cmdOut, cmdErr io.Writer) error { - pushCmd, err := GitCommand("push", "--set-upstream", remote, ref) - if err != nil { - return err + //TODO: Replace with factory GitClient and use AuthenticatedCommand + c := &Client{ + Stdin: cmdIn, + Stdout: cmdOut, + Stderr: cmdErr, } - pushCmd.Stdin = cmdIn - pushCmd.Stdout = cmdOut - pushCmd.Stderr = cmdErr - return run.PrepareCmd(pushCmd).Run() + return c.Push(context.Background(), remote, ref) } -type BranchConfig struct { - RemoteName string - RemoteURL *url.URL - MergeRef string -} - -// ReadBranchConfig parses the `branch.BRANCH.(remote|merge)` part of git config func ReadBranchConfig(branch string) (cfg BranchConfig) { - prefix := regexp.QuoteMeta(fmt.Sprintf("branch.%s.", branch)) - configCmd, err := GitCommand("config", "--get-regexp", fmt.Sprintf("^%s(remote|merge)$", prefix)) - if err != nil { - return - } - output, err := run.PrepareCmd(configCmd).Output() - if err != nil { - return - } - for _, line := range outputLines(output) { - parts := strings.SplitN(line, " ", 2) - if len(parts) < 2 { - continue - } - keys := strings.Split(parts[0], ".") - switch keys[len(keys)-1] { - case "remote": - if strings.Contains(parts[1], ":") { - u, err := ParseURL(parts[1]) - if err != nil { - continue - } - cfg.RemoteURL = u - } else if !isFilesystemPath(parts[1]) { - cfg.RemoteName = parts[1] - } - case "merge": - cfg.MergeRef = parts[1] - } - } - return + c := &Client{} + return c.ReadBranchConfig(context.Background(), branch) } func DeleteLocalBranch(branch string) error { - branchCmd, err := GitCommand("branch", "-D", branch) - if err != nil { - return err - } - return run.PrepareCmd(branchCmd).Run() + c := &Client{} + return c.DeleteLocalBranch(context.Background(), branch) } func HasLocalBranch(branch string) bool { - configCmd, err := GitCommand("rev-parse", "--verify", "refs/heads/"+branch) - if err != nil { - return false - } - _, err = run.PrepareCmd(configCmd).Output() - return err == nil + c := &Client{} + return c.HasLocalBranch(context.Background(), branch) } func CheckoutBranch(branch string) error { - configCmd, err := GitCommand("checkout", branch) - if err != nil { - return err - } - return run.PrepareCmd(configCmd).Run() + c := &Client{} + return c.CheckoutBranch(context.Background(), branch) } func CheckoutNewBranch(remoteName, branch string) error { - track := fmt.Sprintf("%s/%s", remoteName, branch) - configCmd, err := GitCommand("checkout", "-b", branch, "--track", track) - if err != nil { - return err - } - return run.PrepareCmd(configCmd).Run() + c := &Client{} + return c.CheckoutNewBranch(context.Background(), remoteName, branch) } -// pull changes from remote branch without version history func Pull(remote, branch string) error { - pullCmd, err := GitCommand("pull", "--ff-only", remote, branch) - if err != nil { - return err + //TODO: Replace with factory GitClient and use AuthenticatedCommand + c := &Client{ + Stdin: os.Stdin, + Stdout: os.Stdout, + Stderr: os.Stderr, } - - pullCmd.Stdout = os.Stdout - pullCmd.Stderr = os.Stderr - pullCmd.Stdin = os.Stdin - return run.PrepareCmd(pullCmd).Run() -} - -func parseCloneArgs(extraArgs []string) (args []string, target string) { - args = extraArgs - - if len(args) > 0 { - if !strings.HasPrefix(args[0], "-") { - target, args = args[0], args[1:] - } - } - return + return c.Pull(context.Background(), remote, branch) } func RunClone(cloneURL string, args []string) (target string, err error) { - cloneArgs, target := parseCloneArgs(args) - - cloneArgs = append(cloneArgs, cloneURL) - - // If the args contain an explicit target, pass it to clone - // otherwise, parse the URL to determine where git cloned it to so we can return it - if target != "" { - cloneArgs = append(cloneArgs, target) - } else { - target = path.Base(strings.TrimSuffix(cloneURL, ".git")) + //TODO: Replace with factory GitClient and use AuthenticatedCommand + c := &Client{ + Stdin: os.Stdin, + Stdout: os.Stdout, + Stderr: os.Stderr, } - - cloneArgs = append([]string{"clone"}, cloneArgs...) - - cloneCmd, err := GitCommand(cloneArgs...) - if err != nil { - return "", err - } - cloneCmd.Stdin = os.Stdin - cloneCmd.Stdout = os.Stdout - cloneCmd.Stderr = os.Stderr - - err = run.PrepareCmd(cloneCmd).Run() - return + return c.Clone(context.Background(), cloneURL, args) } -func AddNamedRemote(url, name, dir string, branches []string) error { - args := []string{"-C", dir, "remote", "add"} - for _, branch := range branches { - args = append(args, "-t", branch) - } - args = append(args, "-f", name, url) - cloneCmd, err := GitCommand(args...) - if err != nil { - return err - } - cloneCmd.Stdout = os.Stdout - cloneCmd.Stderr = os.Stderr - return run.PrepareCmd(cloneCmd).Run() -} - -func isFilesystemPath(p string) bool { - return p == "." || strings.HasPrefix(p, "./") || strings.HasPrefix(p, "/") -} - -// ToplevelDir returns the top-level directory path of the current repository func ToplevelDir() (string, error) { - showCmd, err := GitCommand("rev-parse", "--show-toplevel") - if err != nil { - return "", err - } - output, err := run.PrepareCmd(showCmd).Output() - return firstLine(output), err - + c := &Client{} + return c.ToplevelDir(context.Background()) } -// ToplevelDirFromPath returns the top-level given path of the current repository -func GetDirFromPath(p string) (string, error) { - showCmd, err := GitCommand("-C", p, "rev-parse", "--git-dir") - if err != nil { - return "", err +func GetDirFromPath(repoDir string) (string, error) { + c := &Client{ + RepoDir: repoDir, } - output, err := run.PrepareCmd(showCmd).Output() - return firstLine(output), err + return c.GitDir(context.Background()) } func PathFromRepoRoot() string { - showCmd, err := GitCommand("rev-parse", "--show-prefix") - if err != nil { - return "" - } - output, err := run.PrepareCmd(showCmd).Output() - if err != nil { - return "" - } - if path := firstLine(output); path != "" { - return path[:len(path)-1] - } - return "" + c := &Client{} + return c.PathFromRoot(context.Background()) } -func outputLines(output []byte) []string { - lines := strings.TrimSuffix(string(output), "\n") - return strings.Split(lines, "\n") - +func Remotes() (RemoteSet, error) { + c := &Client{} + return c.Remotes(context.Background()) } -func firstLine(output []byte) string { - if i := bytes.IndexAny(output, "\n"); i >= 0 { - return string(output)[0:i] +func RemotesForPath(repoDir string) (RemoteSet, error) { + c := &Client{ + RepoDir: repoDir, } - return string(output) + return c.Remotes(context.Background()) } -func getBranchShortName(output []byte) string { - branch := firstLine(output) - return strings.TrimPrefix(branch, "refs/heads/") +func AddRemote(name, url string) (*Remote, error) { + c := &Client{} + return c.AddRemote(context.Background(), name, url, []string{}) +} + +func AddNamedRemote(url, name, repoDir string, branches []string) error { + c := &Client{ + RepoDir: repoDir, + } + _, err := c.AddRemote(context.Background(), name, url, branches) + return err +} + +func UpdateRemoteURL(name, url string) error { + c := &Client{} + return c.UpdateRemoteURL(context.Background(), name, url) +} + +func SetRemoteResolution(name, resolution string) error { + c := &Client{} + return c.SetRemoteResolution(context.Background(), name, resolution) } diff --git a/git/git_test.go b/git/git_test.go deleted file mode 100644 index b5812af9e..000000000 --- a/git/git_test.go +++ /dev/null @@ -1,213 +0,0 @@ -package git - -import ( - "reflect" - "testing" - - "github.com/cli/cli/v2/internal/run" -) - -func TestLastCommit(t *testing.T) { - t.Setenv("GIT_DIR", "./fixtures/simple.git") - c, err := LastCommit() - if err != nil { - t.Fatalf("LastCommit error: %v", err) - } - if c.Sha != "6f1a2405cace1633d89a79c74c65f22fe78f9659" { - t.Errorf("expected sha %q, got %q", "6f1a2405cace1633d89a79c74c65f22fe78f9659", c.Sha) - } - if c.Title != "Second commit" { - t.Errorf("expected title %q, got %q", "Second commit", c.Title) - } -} - -func TestCommitBody(t *testing.T) { - t.Setenv("GIT_DIR", "./fixtures/simple.git") - body, err := CommitBody("6f1a2405cace1633d89a79c74c65f22fe78f9659") - if err != nil { - t.Fatalf("CommitBody error: %v", err) - } - if body != "I'm starting to get the hang of things\n" { - t.Errorf("expected %q, got %q", "I'm starting to get the hang of things\n", body) - } -} - -/* - NOTE: below this are stubbed git tests, i.e. those that do not actually invoke `git`. If possible, utilize - `setGitDir()` to allow new tests to interact with `git`. For write operations, you can use `t.TempDir()` to - host a temporary git repository that is safe to be changed. -*/ - -func Test_UncommittedChangeCount(t *testing.T) { - type c struct { - Label string - Expected int - Output string - } - cases := []c{ - {Label: "no changes", Expected: 0, Output: ""}, - {Label: "one change", Expected: 1, Output: " M poem.txt"}, - {Label: "untracked file", Expected: 2, Output: " M poem.txt\n?? new.txt"}, - } - - for _, v := range cases { - t.Run(v.Label, func(t *testing.T) { - cs, restore := run.Stub() - defer restore(t) - cs.Register(`git status --porcelain`, 0, v.Output) - - ucc, _ := UncommittedChangeCount() - if ucc != v.Expected { - t.Errorf("UncommittedChangeCount() = %d, expected %d", ucc, v.Expected) - } - }) - } -} - -func Test_CurrentBranch(t *testing.T) { - type c struct { - Stub string - Expected string - } - cases := []c{ - { - Stub: "branch-name\n", - Expected: "branch-name", - }, - { - Stub: "refs/heads/branch-name\n", - Expected: "branch-name", - }, - { - Stub: "refs/heads/branch\u00A0with\u00A0non\u00A0breaking\u00A0space\n", - Expected: "branch\u00A0with\u00A0non\u00A0breaking\u00A0space", - }, - } - - for _, v := range cases { - cs, teardown := run.Stub() - cs.Register(`git symbolic-ref --quiet HEAD`, 0, v.Stub) - - result, err := CurrentBranch() - if err != nil { - t.Errorf("got unexpected error: %v", err) - } - if result != v.Expected { - t.Errorf("unexpected branch name: %s instead of %s", result, v.Expected) - } - teardown(t) - } -} - -func Test_CurrentBranch_detached_head(t *testing.T) { - cs, teardown := run.Stub() - defer teardown(t) - cs.Register(`git symbolic-ref --quiet HEAD`, 1, "") - - _, err := CurrentBranch() - if err == nil { - t.Fatal("expected an error, got nil") - } - if err != ErrNotOnAnyBranch { - t.Errorf("got unexpected error: %s instead of %s", err, ErrNotOnAnyBranch) - } -} - -func TestParseExtraCloneArgs(t *testing.T) { - type Wanted struct { - args []string - dir string - } - tests := []struct { - name string - args []string - want Wanted - }{ - { - name: "args and target", - args: []string{"target_directory", "-o", "upstream", "--depth", "1"}, - want: Wanted{ - args: []string{"-o", "upstream", "--depth", "1"}, - dir: "target_directory", - }, - }, - { - name: "only args", - args: []string{"-o", "upstream", "--depth", "1"}, - want: Wanted{ - args: []string{"-o", "upstream", "--depth", "1"}, - dir: "", - }, - }, - { - name: "only target", - args: []string{"target_directory"}, - want: Wanted{ - args: []string{}, - dir: "target_directory", - }, - }, - { - name: "no args", - args: []string{}, - want: Wanted{ - args: []string{}, - dir: "", - }, - }, - } - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - args, dir := parseCloneArgs(tt.args) - got := Wanted{ - args: args, - dir: dir, - } - - if !reflect.DeepEqual(got, tt.want) { - t.Errorf("got %#v want %#v", got, tt.want) - } - }) - } -} - -func TestAddNamedRemote(t *testing.T) { - tests := []struct { - title string - name string - url string - dir string - branches []string - want string - }{ - { - title: "fetch all", - name: "test", - url: "URL", - dir: "DIRECTORY", - branches: []string{}, - want: "git -C DIRECTORY remote add -f test URL", - }, - { - title: "fetch specific branches only", - name: "test", - url: "URL", - dir: "DIRECTORY", - branches: []string{"trunk", "dev"}, - want: "git -C DIRECTORY remote add -t trunk -t dev -f test URL", - }, - } - for _, tt := range tests { - t.Run(tt.title, func(t *testing.T) { - cs, cmdTeardown := run.Stub() - defer cmdTeardown(t) - - cs.Register(tt.want, 0, "") - - err := AddNamedRemote(tt.url, tt.name, tt.dir, tt.branches) - if err != nil { - t.Fatalf("error running command `git remote add -f`: %v", err) - } - }) - } -} diff --git a/git/objects.go b/git/objects.go new file mode 100644 index 000000000..952b6c335 --- /dev/null +++ b/git/objects.go @@ -0,0 +1,76 @@ +package git + +import ( + "net/url" + "strings" +) + +// RemoteSet is a slice of git remotes. +type RemoteSet []*Remote + +func (r RemoteSet) Len() int { return len(r) } +func (r RemoteSet) Swap(i, j int) { r[i], r[j] = r[j], r[i] } +func (r RemoteSet) Less(i, j int) bool { + return remoteNameSortScore(r[i].Name) > remoteNameSortScore(r[j].Name) +} + +func remoteNameSortScore(name string) int { + switch strings.ToLower(name) { + case "upstream": + return 3 + case "github": + return 2 + case "origin": + return 1 + default: + return 0 + } +} + +// Remote is a parsed git remote. +type Remote struct { + Name string + Resolved string + FetchURL *url.URL + PushURL *url.URL +} + +func (r *Remote) String() string { + return r.Name +} + +func NewRemote(name string, u string) *Remote { + pu, _ := url.Parse(u) + return &Remote{ + Name: name, + FetchURL: pu, + PushURL: pu, + } +} + +// Ref represents a git commit reference. +type Ref struct { + Hash string + Name string +} + +// TrackingRef represents a ref for a remote tracking branch. +type TrackingRef struct { + RemoteName string + BranchName string +} + +func (r TrackingRef) String() string { + return "refs/remotes/" + r.RemoteName + "/" + r.BranchName +} + +type Commit struct { + Sha string + Title string +} + +type BranchConfig struct { + RemoteName string + RemoteURL *url.URL + MergeRef string +} diff --git a/git/remote.go b/git/remote.go deleted file mode 100644 index bea81da90..000000000 --- a/git/remote.go +++ /dev/null @@ -1,169 +0,0 @@ -package git - -import ( - "fmt" - "net/url" - "regexp" - "strings" - - "github.com/cli/cli/v2/internal/run" -) - -var remoteRE = regexp.MustCompile(`(.+)\s+(.+)\s+\((push|fetch)\)`) - -// RemoteSet is a slice of git remotes -type RemoteSet []*Remote - -func NewRemote(name string, u string) *Remote { - pu, _ := url.Parse(u) - return &Remote{ - Name: name, - FetchURL: pu, - PushURL: pu, - } -} - -// Remote is a parsed git remote -type Remote struct { - Name string - Resolved string - FetchURL *url.URL - PushURL *url.URL -} - -func (r *Remote) String() string { - return r.Name -} - -func remotes(path string, remoteList []string) (RemoteSet, error) { - remotes := parseRemotes(remoteList) - - // this is affected by SetRemoteResolution - remoteCmd, err := GitCommand("-C", path, "config", "--get-regexp", `^remote\..*\.gh-resolved$`) - if err != nil { - return nil, err - } - output, _ := run.PrepareCmd(remoteCmd).Output() - for _, l := range outputLines(output) { - parts := strings.SplitN(l, " ", 2) - if len(parts) < 2 { - continue - } - rp := strings.SplitN(parts[0], ".", 3) - if len(rp) < 2 { - continue - } - name := rp[1] - for _, r := range remotes { - if r.Name == name { - r.Resolved = parts[1] - break - } - } - } - - return remotes, nil -} - -func RemotesForPath(path string) (RemoteSet, error) { - list, err := listRemotesForPath(path) - if err != nil { - return nil, err - } - return remotes(path, list) -} - -// Remotes gets the git remotes set for the current repo -func Remotes() (RemoteSet, error) { - list, err := listRemotes() - if err != nil { - return nil, err - } - return remotes(".", list) -} - -func parseRemotes(gitRemotes []string) (remotes RemoteSet) { - for _, r := range gitRemotes { - match := remoteRE.FindStringSubmatch(r) - if match == nil { - continue - } - name := strings.TrimSpace(match[1]) - urlStr := strings.TrimSpace(match[2]) - urlType := strings.TrimSpace(match[3]) - - var rem *Remote - if len(remotes) > 0 { - rem = remotes[len(remotes)-1] - if name != rem.Name { - rem = nil - } - } - if rem == nil { - rem = &Remote{Name: name} - remotes = append(remotes, rem) - } - - u, err := ParseURL(urlStr) - if err != nil { - continue - } - - switch urlType { - case "fetch": - rem.FetchURL = u - case "push": - rem.PushURL = u - } - } - return -} - -// AddRemote adds a new git remote and auto-fetches objects from it -func AddRemote(name, u string) (*Remote, error) { - addCmd, err := GitCommand("remote", "add", "-f", name, u) - if err != nil { - return nil, err - } - err = run.PrepareCmd(addCmd).Run() - if err != nil { - return nil, err - } - - var urlParsed *url.URL - if strings.HasPrefix(u, "https") { - urlParsed, err = url.Parse(u) - if err != nil { - return nil, err - } - - } else { - urlParsed, err = ParseURL(u) - if err != nil { - return nil, err - } - - } - - return &Remote{ - Name: name, - FetchURL: urlParsed, - PushURL: urlParsed, - }, nil -} - -func UpdateRemoteURL(name, u string) error { - addCmd, err := GitCommand("remote", "set-url", name, u) - if err != nil { - return err - } - return run.PrepareCmd(addCmd).Run() -} - -func SetRemoteResolution(name, resolution string) error { - addCmd, err := GitCommand("config", "--add", fmt.Sprintf("remote.%s.gh-resolved", name), resolution) - if err != nil { - return err - } - return run.PrepareCmd(addCmd).Run() -} diff --git a/git/remote_test.go b/git/remote_test.go deleted file mode 100644 index 382896590..000000000 --- a/git/remote_test.go +++ /dev/null @@ -1,35 +0,0 @@ -package git - -import ( - "testing" - - "github.com/stretchr/testify/assert" -) - -func Test_parseRemotes(t *testing.T) { - remoteList := []string{ - "mona\tgit@github.com:monalisa/myfork.git (fetch)", - "origin\thttps://github.com/monalisa/octo-cat.git (fetch)", - "origin\thttps://github.com/monalisa/octo-cat-push.git (push)", - "upstream\thttps://example.com/nowhere.git (fetch)", - "upstream\thttps://github.com/hubot/tools (push)", - "zardoz\thttps://example.com/zed.git (push)", - } - r := parseRemotes(remoteList) - assert.Equal(t, 4, len(r)) - - assert.Equal(t, "mona", r[0].Name) - assert.Equal(t, "ssh://git@github.com/monalisa/myfork.git", r[0].FetchURL.String()) - if r[0].PushURL != nil { - t.Errorf("expected no PushURL, got %q", r[0].PushURL) - } - assert.Equal(t, "origin", r[1].Name) - assert.Equal(t, "/monalisa/octo-cat.git", r[1].FetchURL.Path) - assert.Equal(t, "/monalisa/octo-cat-push.git", r[1].PushURL.Path) - - assert.Equal(t, "upstream", r[2].Name) - assert.Equal(t, "example.com", r[2].FetchURL.Host) - assert.Equal(t, "github.com", r[2].PushURL.Host) - - assert.Equal(t, "zardoz", r[3].Name) -} diff --git a/pkg/cmd/auth/shared/git_credential.go b/pkg/cmd/auth/shared/git_credential.go index 9c9a1cf6b..491e2c82f 100644 --- a/pkg/cmd/auth/shared/git_credential.go +++ b/pkg/cmd/auth/shared/git_credential.go @@ -10,7 +10,6 @@ import ( "github.com/MakeNowJust/heredoc" "github.com/cli/cli/v2/git" "github.com/cli/cli/v2/internal/ghinstance" - "github.com/cli/cli/v2/internal/run" "github.com/google/shlex" ) @@ -82,7 +81,7 @@ func (flow *GitCredentialFlow) gitCredentialSetup(hostname, username, password s configErr = err break } - if err = run.PrepareCmd(preConfigureCmd).Run(); err != nil { + if err = preConfigureCmd.Run(); err != nil { configErr = err break } @@ -96,7 +95,7 @@ func (flow *GitCredentialFlow) gitCredentialSetup(hostname, username, password s if err != nil { configErr = err } else { - configErr = run.PrepareCmd(configureCmd).Run() + configErr = configureCmd.Run() } } @@ -114,7 +113,7 @@ func (flow *GitCredentialFlow) gitCredentialSetup(hostname, username, password s host=%s `, hostname)) - err = run.PrepareCmd(rejectCmd).Run() + err = rejectCmd.Run() if err != nil { return err } @@ -131,7 +130,7 @@ func (flow *GitCredentialFlow) gitCredentialSetup(hostname, username, password s password=%s `, hostname, username, password)) - err = run.PrepareCmd(approveCmd).Run() + err = approveCmd.Run() if err != nil { return err } diff --git a/pkg/cmd/factory/default.go b/pkg/cmd/factory/default.go index 4f05b7c03..77552c977 100644 --- a/pkg/cmd/factory/default.go +++ b/pkg/cmd/factory/default.go @@ -35,6 +35,7 @@ func New(appVersion string) *cmdutil.Factory { f.BaseRepo = BaseRepoFunc(f) // Depends on Remotes f.Prompter = newPrompter(f) // Depends on Config and IOStreams f.Browser = newBrowser(f) // Depends on Config, and IOStreams + f.GitClient = newGitClient(f) // Depends on IOStreams, and Executable f.ExtensionManager = extensionManager(f) // Depends on Config, HttpClient, and IOStreams return f @@ -106,6 +107,18 @@ func httpClientFunc(f *cmdutil.Factory, appVersion string) func() (*http.Client, } } +func newGitClient(f *cmdutil.Factory) *git.Client { + io := f.IOStreams + ghPath := f.Executable() + client := &git.Client{ + GhPath: ghPath, + Stderr: io.ErrOut, + Stdin: io.In, + Stdout: io.Out, + } + return client +} + func newBrowser(f *cmdutil.Factory) browser.Browser { io := f.IOStreams return browser.New("", io.Out, io.ErrOut) diff --git a/pkg/cmd/factory/default_test.go b/pkg/cmd/factory/default_test.go index feef3e81f..2ba35b427 100644 --- a/pkg/cmd/factory/default_test.go +++ b/pkg/cmd/factory/default_test.go @@ -4,6 +4,7 @@ import ( "net/http" "net/http/httptest" "net/url" + "path/filepath" "testing" "github.com/cli/cli/v2/git" @@ -435,6 +436,44 @@ func TestSSOURL(t *testing.T) { } } +func TestNewGitClient(t *testing.T) { + tests := []struct { + name string + config config.Config + executable string + wantAuthHosts []string + wantGhPath string + }{ + { + name: "creates git client", + config: defaultConfig(), + executable: filepath.Join("path", "to", "gh"), + wantAuthHosts: []string{"nonsense.com"}, + wantGhPath: filepath.Join("path", "to", "gh"), + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + f := New("1") + f.Config = func() (config.Config, error) { + if tt.config == nil { + return config.NewBlankConfig(), nil + } else { + return tt.config, nil + } + } + f.ExecutableName = tt.executable + ios, _, _, _ := iostreams.Test() + f.IOStreams = ios + c := newGitClient(f) + assert.Equal(t, tt.wantGhPath, c.GhPath) + assert.Equal(t, ios.In, c.Stdin) + assert.Equal(t, ios.Out, c.Stdout) + assert.Equal(t, ios.ErrOut, c.Stderr) + }) + } +} + func defaultConfig() *config.ConfigMock { cfg := config.NewFromString("") cfg.Set("nonsense.com", "oauth_token", "BLAH") diff --git a/pkg/cmd/pr/checkout/checkout.go b/pkg/cmd/pr/checkout/checkout.go index 1063d9f36..73c8f40d1 100644 --- a/pkg/cmd/pr/checkout/checkout.go +++ b/pkg/cmd/pr/checkout/checkout.go @@ -1,22 +1,19 @@ package checkout import ( + "context" "fmt" "net/http" - "os" - "os/exec" "strings" "github.com/cli/cli/v2/api" - "github.com/cli/cli/v2/context" + cliContext "github.com/cli/cli/v2/context" "github.com/cli/cli/v2/git" "github.com/cli/cli/v2/internal/config" "github.com/cli/cli/v2/internal/ghrepo" - "github.com/cli/cli/v2/internal/run" "github.com/cli/cli/v2/pkg/cmd/pr/shared" "github.com/cli/cli/v2/pkg/cmdutil" "github.com/cli/cli/v2/pkg/iostreams" - "github.com/cli/safeexec" "github.com/spf13/cobra" ) @@ -24,7 +21,7 @@ type CheckoutOptions struct { HttpClient func() (*http.Client, error) Config func() (config.Config, error) IO *iostreams.IOStreams - Remotes func() (context.Remotes, error) + Remotes func() (cliContext.Remotes, error) Branch func() (string, error) Finder shared.PRFinder @@ -131,7 +128,7 @@ func checkoutRun(opts *CheckoutOptions) error { cmdQueue = append(cmdQueue, []string{"git", "submodule", "update", "--init", "--recursive"}) } - err = executeCmds(cmdQueue) + err = executeCmds(cmdQueue, opts.IO) if err != nil { return err } @@ -139,7 +136,7 @@ func checkoutRun(opts *CheckoutOptions) error { return nil } -func cmdsForExistingRemote(remote *context.Remote, pr *api.PullRequest, opts *CheckoutOptions) [][]string { +func cmdsForExistingRemote(remote *cliContext.Remote, pr *api.PullRequest, opts *CheckoutOptions) [][]string { var cmds [][]string remoteBranch := fmt.Sprintf("%s/%s", remote.Name, pr.HeadRefName) @@ -241,17 +238,19 @@ func localBranchExists(b string) bool { return err == nil } -func executeCmds(cmdQueue [][]string) error { +func executeCmds(cmdQueue [][]string, ios *iostreams.IOStreams) error { + //TODO: Replace with factory GitClient + //TODO: Use AuthenticatedCommand + client := git.Client{ + Stdout: ios.Out, + Stderr: ios.ErrOut, + } for _, args := range cmdQueue { - // TODO: reuse the result of this lookup across loop iteration - exe, err := safeexec.LookPath(args[0]) + cmd, err := client.Command(context.Background(), args[1:]...) if err != nil { return err } - cmd := exec.Command(exe, args[1:]...) - cmd.Stdout = os.Stdout - cmd.Stderr = os.Stderr - if err := run.PrepareCmd(cmd).Run(); err != nil { + if err := cmd.Run(); err != nil { return err } } diff --git a/pkg/cmd/release/create/create.go b/pkg/cmd/release/create/create.go index 73c74e18f..e6f97dd9f 100644 --- a/pkg/cmd/release/create/create.go +++ b/pkg/cmd/release/create/create.go @@ -13,7 +13,6 @@ import ( "github.com/cli/cli/v2/git" "github.com/cli/cli/v2/internal/config" "github.com/cli/cli/v2/internal/ghrepo" - "github.com/cli/cli/v2/internal/run" "github.com/cli/cli/v2/internal/text" "github.com/cli/cli/v2/pkg/cmd/release/shared" "github.com/cli/cli/v2/pkg/cmdutil" @@ -475,7 +474,7 @@ func gitTagInfo(tagName string) (string, error) { if err != nil { return "", err } - b, err := run.PrepareCmd(cmd).Output() + b, err := cmd.Output() return string(b), err } @@ -484,7 +483,7 @@ func detectPreviousTag(headRef string) (string, error) { if err != nil { return "", err } - b, err := run.PrepareCmd(cmd).Output() + b, err := cmd.Output() return strings.TrimSpace(string(b)), err } @@ -498,7 +497,7 @@ func changelogForRange(refRange string) ([]logEntry, error) { if err != nil { return nil, err } - b, err := run.PrepareCmd(cmd).Output() + b, err := cmd.Output() if err != nil { return nil, err } diff --git a/pkg/cmd/repo/create/create.go b/pkg/cmd/repo/create/create.go index b1eb2318e..e311ad727 100644 --- a/pkg/cmd/repo/create/create.go +++ b/pkg/cmd/repo/create/create.go @@ -13,7 +13,6 @@ import ( "github.com/cli/cli/v2/git" "github.com/cli/cli/v2/internal/config" "github.com/cli/cli/v2/internal/ghrepo" - "github.com/cli/cli/v2/internal/run" "github.com/cli/cli/v2/pkg/cmd/repo/shared" "github.com/cli/cli/v2/pkg/cmdutil" "github.com/cli/cli/v2/pkg/iostreams" @@ -552,7 +551,7 @@ func createFromLocal(opts *CreateOptions) error { if err != nil { return err } - err = run.PrepareCmd(repoPush).Run() + err = repoPush.Run() if err != nil { return err } @@ -574,7 +573,7 @@ func sourceInit(io *iostreams.IOStreams, remoteURL, baseRemote, repoPath string) return err } - err = run.PrepareCmd(remoteAdd).Run() + err = remoteAdd.Run() if err != nil { return fmt.Errorf("%s Unable to add remote %q", cs.FailureIcon(), baseRemote) } @@ -590,8 +589,7 @@ func hasCommits(repoPath string) (bool, error) { if err != nil { return false, err } - prepareCmd := run.PrepareCmd(hasCommitsCmd) - err = prepareCmd.Run() + err = hasCommitsCmd.Run() if err == nil { return true, nil } @@ -636,7 +634,7 @@ func localInit(io *iostreams.IOStreams, remoteURL, path, checkoutBranch string) gitInit.Stdout = io.Out } gitInit.Stderr = io.ErrOut - err = run.PrepareCmd(gitInit).Run() + err = gitInit.Run() if err != nil { return err } @@ -647,7 +645,7 @@ func localInit(io *iostreams.IOStreams, remoteURL, path, checkoutBranch string) } gitRemoteAdd.Stdout = io.Out gitRemoteAdd.Stderr = io.ErrOut - err = run.PrepareCmd(gitRemoteAdd).Run() + err = gitRemoteAdd.Run() if err != nil { return err } @@ -662,7 +660,7 @@ func localInit(io *iostreams.IOStreams, remoteURL, path, checkoutBranch string) } gitFetch.Stdout = io.Out gitFetch.Stderr = io.ErrOut - err = run.PrepareCmd(gitFetch).Run() + err = gitFetch.Run() if err != nil { return err } @@ -673,7 +671,7 @@ func localInit(io *iostreams.IOStreams, remoteURL, path, checkoutBranch string) } gitCheckout.Stdout = io.Out gitCheckout.Stderr = io.ErrOut - return run.PrepareCmd(gitCheckout).Run() + return gitCheckout.Run() } func interactiveGitIgnore(client *http.Client, hostname string, prompter iprompter) (string, error) { diff --git a/pkg/cmd/repo/create/create_test.go b/pkg/cmd/repo/create/create_test.go index 6c7c515d3..f8849afd1 100644 --- a/pkg/cmd/repo/create/create_test.go +++ b/pkg/cmd/repo/create/create_test.go @@ -492,18 +492,18 @@ func Test_createRun(t *testing.T) { return config.NewBlankConfig(), nil } - cs, restoreRun := run.Stub() - defer restoreRun(t) - if tt.execStubs != nil { - tt.execStubs(cs) - } - ios, _, stdout, stderr := iostreams.Test() ios.SetStdinTTY(tt.tty) ios.SetStdoutTTY(tt.tty) tt.opts.IO = ios t.Run(tt.name, func(t *testing.T) { + cs, restoreRun := run.Stub() + defer restoreRun(t) + if tt.execStubs != nil { + tt.execStubs(cs) + } + defer reg.Verify(t) err := createRun(tt.opts) if tt.wantErr { diff --git a/pkg/cmd/repo/fork/fork.go b/pkg/cmd/repo/fork/fork.go index e3fe80247..9416ba4b2 100644 --- a/pkg/cmd/repo/fork/fork.go +++ b/pkg/cmd/repo/fork/fork.go @@ -13,7 +13,6 @@ import ( "github.com/cli/cli/v2/git" "github.com/cli/cli/v2/internal/config" "github.com/cli/cli/v2/internal/ghrepo" - "github.com/cli/cli/v2/internal/run" "github.com/cli/cli/v2/pkg/cmd/repo/shared" "github.com/cli/cli/v2/pkg/cmdutil" "github.com/cli/cli/v2/pkg/iostreams" @@ -279,7 +278,7 @@ func forkRun(opts *ForkOptions) error { if err != nil { return err } - err = run.PrepareCmd(renameCmd).Run() + err = renameCmd.Run() if err != nil { return err } diff --git a/pkg/cmdutil/factory.go b/pkg/cmdutil/factory.go index d7357f1fa..e00e1a89a 100644 --- a/pkg/cmdutil/factory.go +++ b/pkg/cmdutil/factory.go @@ -7,6 +7,7 @@ import ( "strings" "github.com/cli/cli/v2/context" + "github.com/cli/cli/v2/git" "github.com/cli/cli/v2/internal/browser" "github.com/cli/cli/v2/internal/config" "github.com/cli/cli/v2/internal/ghrepo" @@ -19,6 +20,7 @@ type Factory struct { IOStreams *iostreams.IOStreams Prompter prompter.Prompter Browser browser.Browser + GitClient *git.Client HttpClient func() (*http.Client, error) BaseRepo func() (ghrepo.Interface, error) From c89d0402bb30458c3f9cfc211d5190d6945b5441 Mon Sep 17 00:00:00 2001 From: Sam Coe Date: Fri, 14 Oct 2022 12:46:12 +0300 Subject: [PATCH 31/44] Improved error messaging for repo sync scenario (#6430) --- pkg/cmd/repo/sync/sync.go | 19 +++++++++++++++---- pkg/cmd/repo/sync/sync_test.go | 33 +++++++++++++++++++++++++++++++++ 2 files changed, 48 insertions(+), 4 deletions(-) diff --git a/pkg/cmd/repo/sync/sync.go b/pkg/cmd/repo/sync/sync.go index 5e0841041..ba2fdbafb 100644 --- a/pkg/cmd/repo/sync/sync.go +++ b/pkg/cmd/repo/sync/sync.go @@ -4,7 +4,6 @@ import ( "errors" "fmt" "net/http" - "regexp" "strings" "github.com/MakeNowJust/heredoc" @@ -17,6 +16,11 @@ import ( "github.com/spf13/cobra" ) +const ( + notFastForwardErrorMessage = "Update is not a fast forward" + branchDoesNotExistErrorMessage = "Reference does not exist" +) + type SyncOptions struct { HttpClient func() (*http.Client, error) IO *iostreams.IOStreams @@ -315,11 +319,18 @@ func executeRemoteRepoSync(client *api.Client, destRepo, srcRepo ghrepo.Interfac // This is not a great way to detect the error returned by the API // Unfortunately API returns 422 for multiple reasons - notFastForwardErrorMessage := regexp.MustCompile(`^Update is not a fast forward$`) err = syncFork(client, destRepo, branchName, commit.Object.SHA, opts.Force) var httpErr api.HTTPError - if err != nil && errors.As(err, &httpErr) && notFastForwardErrorMessage.MatchString(httpErr.Message) { - return "", divergingError + if err != nil { + if errors.As(err, &httpErr) { + switch httpErr.Message { + case notFastForwardErrorMessage: + return "", divergingError + case branchDoesNotExistErrorMessage: + return "", fmt.Errorf("%s branch does not exist on %s repository", branchName, ghrepo.FullName(destRepo)) + } + } + return "", err } return fmt.Sprintf("%s:%s", srcRepo.RepoOwner(), branchName), nil diff --git a/pkg/cmd/repo/sync/sync_test.go b/pkg/cmd/repo/sync/sync_test.go index 8b72bf1f8..9e7e5b5b1 100644 --- a/pkg/cmd/repo/sync/sync_test.go +++ b/pkg/cmd/repo/sync/sync_test.go @@ -424,6 +424,39 @@ func Test_SyncRun(t *testing.T) { wantErr: true, errMsg: "can't sync because there are diverging changes; use `--force` to overwrite the destination branch", }, + { + name: "sync remote fork with parent and no existing branch on fork", + tty: true, + opts: &SyncOptions{ + DestArg: "OWNER/REPO-FORK", + }, + httpStubs: func(reg *httpmock.Registry) { + reg.Register( + httpmock.GraphQL(`query RepositoryFindParent\b`), + httpmock.StringResponse(`{"data":{"repository":{"parent":{"name":"REPO","owner":{"login": "OWNER"}}}}}`)) + reg.Register( + httpmock.GraphQL(`query RepositoryInfo\b`), + httpmock.StringResponse(`{"data":{"repository":{"defaultBranchRef":{"name": "trunk"}}}}`)) + reg.Register( + httpmock.REST("POST", "repos/OWNER/REPO-FORK/merge-upstream"), + httpmock.StatusStringResponse(409, `{"message": "Merge conflict"}`)) + reg.Register( + httpmock.REST("GET", "repos/OWNER/REPO/git/refs/heads/trunk"), + httpmock.StringResponse(`{"object":{"sha":"0xDEADBEEF"}}`)) + reg.Register( + httpmock.REST("PATCH", "repos/OWNER/REPO-FORK/git/refs/heads/trunk"), + func(req *http.Request) (*http.Response, error) { + return &http.Response{ + StatusCode: 422, + Request: req, + Header: map[string][]string{"Content-Type": {"application/json"}}, + Body: io.NopCloser(bytes.NewBufferString(`{"message":"Reference does not exist"}`)), + }, nil + }) + }, + wantErr: true, + errMsg: "trunk branch does not exist on OWNER/REPO-FORK repository", + }, } for _, tt := range tests { reg := &httpmock.Registry{} From abb8c86ee80904c4d035086faa8ab293dbf9f670 Mon Sep 17 00:00:00 2001 From: Ariel Deitcher <1149246+mntlty@users.noreply.github.com> Date: Fri, 14 Oct 2022 16:27:15 +0300 Subject: [PATCH 32/44] mntly/extension not found (#6425) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: Mislav Marohnić --- pkg/cmd/extension/command.go | 3 +++ pkg/cmd/extension/command_test.go | 19 +++++++++++++ pkg/cmd/extension/http.go | 44 ++++++++++++++++++++++++------- pkg/cmd/extension/manager.go | 15 ++++++----- pkg/cmd/extension/manager_test.go | 26 ++++++++++++++++++ 5 files changed, 92 insertions(+), 15 deletions(-) diff --git a/pkg/cmd/extension/command.go b/pkg/cmd/extension/command.go index b19b80b55..50112b717 100644 --- a/pkg/cmd/extension/command.go +++ b/pkg/cmd/extension/command.go @@ -124,6 +124,9 @@ func NewCmdExtension(f *cmdutil.Factory) *cobra.Command { } else if errors.Is(err, commitNotFoundErr) { return fmt.Errorf("%s %s does not exist in %s", cs.FailureIcon(), cs.Cyan(pinFlag), args[0]) + } else if errors.Is(err, repositoryNotFoundErr) { + return fmt.Errorf("%s Could not find extension '%s' on host %s", + cs.FailureIcon(), args[0], repo.RepoHost()) } return err } diff --git a/pkg/cmd/extension/command_test.go b/pkg/cmd/extension/command_test.go index fb4707320..46bc109af 100644 --- a/pkg/cmd/extension/command_test.go +++ b/pkg/cmd/extension/command_test.go @@ -87,6 +87,25 @@ func TestNewCmdExtension(t *testing.T) { } }, }, + { + name: "error extension not found", + args: []string{"install", "owner/gh-some-ext"}, + managerStubs: func(em *extensions.ExtensionManagerMock) func(*testing.T) { + em.ListFunc = func() []extensions.Extension { + return []extensions.Extension{} + } + em.InstallFunc = func(_ ghrepo.Interface, _ string) error { + return repositoryNotFoundErr + } + return func(t *testing.T) { + installCalls := em.InstallCalls() + assert.Equal(t, 1, len(installCalls)) + assert.Equal(t, "gh-some-ext", installCalls[0].InterfaceMoqParam.RepoName()) + } + }, + wantErr: true, + errMsg: "X Could not find extension 'owner/gh-some-ext' on host github.com", + }, { name: "install local extension with pin", args: []string{"install", ".", "--pin", "v1.0.0"}, diff --git a/pkg/cmd/extension/http.go b/pkg/cmd/extension/http.go index 5e3c23d69..2fae2f023 100644 --- a/pkg/cmd/extension/http.go +++ b/pkg/cmd/extension/http.go @@ -13,32 +13,54 @@ import ( "github.com/cli/cli/v2/internal/ghrepo" ) -func hasScript(httpClient *http.Client, repo ghrepo.Interface) (hs bool, err error) { +func repoExists(httpClient *http.Client, repo ghrepo.Interface) (bool, error) { + url := fmt.Sprintf("%srepos/%s/%s", ghinstance.RESTPrefix(repo.RepoHost()), repo.RepoOwner(), repo.RepoName()) + req, err := http.NewRequest("GET", url, nil) + if err != nil { + return false, err + } + + resp, err := httpClient.Do(req) + if err != nil { + return false, err + } + defer resp.Body.Close() + + switch resp.StatusCode { + case 200: + return true, nil + case 404: + return false, nil + default: + return false, api.HandleHTTPError(resp) + } +} + +func hasScript(httpClient *http.Client, repo ghrepo.Interface) (bool, error) { path := fmt.Sprintf("repos/%s/%s/contents/%s", repo.RepoOwner(), repo.RepoName(), repo.RepoName()) url := ghinstance.RESTPrefix(repo.RepoHost()) + path req, err := http.NewRequest("GET", url, nil) if err != nil { - return + return false, err } resp, err := httpClient.Do(req) if err != nil { - return + return false, err } defer resp.Body.Close() if resp.StatusCode == 404 { - return + return false, nil } if resp.StatusCode > 299 { err = api.HandleHTTPError(resp) - return + return false, err } - hs = true - return + return true, nil } type releaseAsset struct { @@ -80,8 +102,9 @@ func downloadAsset(httpClient *http.Client, asset releaseAsset, destPath string) return err } -var releaseNotFoundErr = errors.New("release not found") var commitNotFoundErr = errors.New("commit not found") +var releaseNotFoundErr = errors.New("release not found") +var repositoryNotFoundErr = errors.New("repository not found") // fetchLatestRelease finds the latest published release for a repository. func fetchLatestRelease(httpClient *http.Client, baseRepo ghrepo.Interface) (*release, error) { @@ -98,6 +121,9 @@ func fetchLatestRelease(httpClient *http.Client, baseRepo ghrepo.Interface) (*re } defer resp.Body.Close() + if resp.StatusCode == 404 { + return nil, releaseNotFoundErr + } if resp.StatusCode > 299 { return nil, api.HandleHTTPError(resp) } @@ -162,7 +188,7 @@ func fetchCommitSHA(httpClient *http.Client, baseRepo ghrepo.Interface, targetRe return "", err } - req.Header.Set("Accept", "application/vnd.github.VERSION.sha") + req.Header.Set("Accept", "application/vnd.github.v3.sha") resp, err := httpClient.Do(req) if err != nil { return "", err diff --git a/pkg/cmd/extension/manager.go b/pkg/cmd/extension/manager.go index 8ca79537d..289a9aefa 100644 --- a/pkg/cmd/extension/manager.go +++ b/pkg/cmd/extension/manager.go @@ -339,7 +339,15 @@ type binManifest struct { func (m *Manager) Install(repo ghrepo.Interface, target string) error { isBin, err := isBinExtension(m.client, repo) if err != nil { - return fmt.Errorf("could not check for binary extension: %w", err) + if errors.Is(err, releaseNotFoundErr) { + if ok, err := repoExists(m.client, repo); err != nil { + return err + } else if !ok { + return repositoryNotFoundErr + } + } else { + return fmt.Errorf("could not check for binary extension: %w", err) + } } if isBin { return m.installBin(repo, target) @@ -760,11 +768,6 @@ func isBinExtension(client *http.Client, repo ghrepo.Interface) (isBin bool, err var r *release r, err = fetchLatestRelease(client, repo) if err != nil { - httpErr, ok := err.(api.HTTPError) - if ok && httpErr.StatusCode == 404 { - err = nil - return - } return } diff --git a/pkg/cmd/extension/manager_test.go b/pkg/cmd/extension/manager_test.go index e29dc0510..2dbd81870 100644 --- a/pkg/cmd/extension/manager_test.go +++ b/pkg/cmd/extension/manager_test.go @@ -844,6 +844,32 @@ func TestManager_Install_binary(t *testing.T) { assert.Equal(t, "", stderr.String()) } +func TestManager_repo_not_found(t *testing.T) { + repo := ghrepo.NewWithHost("owner", "gh-bin-ext", "example.com") + + reg := httpmock.Registry{} + defer reg.Verify(t) + + reg.Register( + httpmock.REST("GET", "api/v3/repos/owner/gh-bin-ext/releases/latest"), + httpmock.StatusStringResponse(404, `{}`)) + reg.Register( + httpmock.REST("GET", "api/v3/repos/owner/gh-bin-ext"), + httpmock.StatusStringResponse(404, `{}`)) + + ios, _, stdout, stderr := iostreams.Test() + tempDir := t.TempDir() + + m := newTestManager(tempDir, &http.Client{Transport: ®}, ios) + + if err := m.Install(repo, ""); err != repositoryNotFoundErr { + t.Errorf("expected repositoryNotFoundErr, got: %v", err) + } + + assert.Equal(t, "", stdout.String()) + assert.Equal(t, "", stderr.String()) +} + func TestManager_Create(t *testing.T) { chdirTemp(t) ios, _, stdout, stderr := iostreams.Test() From 4265b5b80499fce9d5713fe4c1702f08442a4359 Mon Sep 17 00:00:00 2001 From: Natthakit Susanthitanon Date: Mon, 17 Oct 2022 13:07:51 +0700 Subject: [PATCH 33/44] Add `gpg-key delete` command (#6360) --- pkg/cmd/gpg-key/delete/delete.go | 101 +++++++++++++ pkg/cmd/gpg-key/delete/delete_test.go | 210 ++++++++++++++++++++++++++ pkg/cmd/gpg-key/delete/http.go | 68 +++++++++ pkg/cmd/gpg-key/gpg_key.go | 4 +- 4 files changed, 382 insertions(+), 1 deletion(-) create mode 100644 pkg/cmd/gpg-key/delete/delete.go create mode 100644 pkg/cmd/gpg-key/delete/delete_test.go create mode 100644 pkg/cmd/gpg-key/delete/http.go diff --git a/pkg/cmd/gpg-key/delete/delete.go b/pkg/cmd/gpg-key/delete/delete.go new file mode 100644 index 000000000..bb5277a38 --- /dev/null +++ b/pkg/cmd/gpg-key/delete/delete.go @@ -0,0 +1,101 @@ +package delete + +import ( + "fmt" + "net/http" + "strconv" + + "github.com/cli/cli/v2/internal/config" + "github.com/cli/cli/v2/internal/prompter" + "github.com/cli/cli/v2/pkg/cmdutil" + "github.com/cli/cli/v2/pkg/iostreams" + "github.com/spf13/cobra" +) + +type DeleteOptions struct { + IO *iostreams.IOStreams + Config func() (config.Config, error) + HttpClient func() (*http.Client, error) + + KeyID string + Confirmed bool + Prompter prompter.Prompter +} + +func NewCmdDelete(f *cmdutil.Factory, runF func(*DeleteOptions) error) *cobra.Command { + opts := &DeleteOptions{ + HttpClient: f.HttpClient, + Config: f.Config, + IO: f.IOStreams, + Prompter: f.Prompter, + } + + cmd := &cobra.Command{ + Use: "delete ", + Short: "Delete a GPG key from your GitHub account", + Args: cmdutil.ExactArgs(1, "cannot delete: key id required"), + RunE: func(cmd *cobra.Command, args []string) error { + opts.KeyID = args[0] + + if !opts.IO.CanPrompt() && !opts.Confirmed { + return cmdutil.FlagErrorf("--confirm required when not running interactively") + } + + if runF != nil { + return runF(opts) + } + return deleteRun(opts) + }, + } + + cmd.Flags().BoolVarP(&opts.Confirmed, "confirm", "y", false, "Skip the confirmation prompt") + return cmd +} + +func deleteRun(opts *DeleteOptions) error { + httpClient, err := opts.HttpClient() + if err != nil { + return err + } + + cfg, err := opts.Config() + if err != nil { + return err + } + + host, _ := cfg.DefaultHost() + gpgKeys, err := getGPGKeys(httpClient, host) + if err != nil { + return err + } + + id := "" + for _, gpgKey := range gpgKeys { + if gpgKey.KeyID == opts.KeyID { + id = strconv.Itoa(gpgKey.ID) + break + } + } + + if id == "" { + return fmt.Errorf("unable to delete GPG key %s: either the GPG key is not found or it is not owned by you", opts.KeyID) + } + + if !opts.Confirmed { + if err := opts.Prompter.ConfirmDeletion(opts.KeyID); err != nil { + return err + } + } + + err = deleteGPGKey(httpClient, host, id) + if err != nil { + return nil + } + + if opts.IO.IsStdoutTTY() { + cs := opts.IO.ColorScheme() + fmt.Fprintf(opts.IO.Out, "%s GPG key %s deleted from your account\n", cs.SuccessIcon(), opts.KeyID) + } + + return nil +} diff --git a/pkg/cmd/gpg-key/delete/delete_test.go b/pkg/cmd/gpg-key/delete/delete_test.go new file mode 100644 index 000000000..2835f9cb2 --- /dev/null +++ b/pkg/cmd/gpg-key/delete/delete_test.go @@ -0,0 +1,210 @@ +package delete + +import ( + "bytes" + "net/http" + "testing" + + "github.com/cli/cli/v2/internal/config" + "github.com/cli/cli/v2/internal/prompter" + "github.com/cli/cli/v2/pkg/cmdutil" + "github.com/cli/cli/v2/pkg/httpmock" + "github.com/cli/cli/v2/pkg/iostreams" + "github.com/google/shlex" + "github.com/stretchr/testify/assert" +) + +func TestNewCmdDelete(t *testing.T) { + tests := []struct { + name string + tty bool + input string + output DeleteOptions + wantErr bool + wantErrMsg string + }{ + { + name: "tty", + tty: true, + input: "ABC123", + output: DeleteOptions{KeyID: "ABC123", Confirmed: false}, + }, + { + name: "confirm flag tty", + tty: true, + input: "ABC123 --confirm", + output: DeleteOptions{KeyID: "ABC123", Confirmed: true}, + }, + { + name: "shorthand confirm flag tty", + tty: true, + input: "ABC123 -y", + output: DeleteOptions{KeyID: "ABC123", Confirmed: true}, + }, + { + name: "no tty", + input: "ABC123", + wantErr: true, + wantErrMsg: "--confirm required when not running interactively", + }, + { + name: "confirm flag no tty", + input: "ABC123 --confirm", + output: DeleteOptions{KeyID: "ABC123", Confirmed: true}, + }, + { + name: "shorthand confirm flag no tty", + input: "ABC123 -y", + output: DeleteOptions{KeyID: "ABC123", Confirmed: true}, + }, + { + name: "no args", + input: "", + wantErr: true, + wantErrMsg: "cannot delete: key id required", + }, + { + name: "too many args", + input: "ABC123 XYZ", + wantErr: true, + wantErrMsg: "too many arguments", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + ios, _, _, _ := iostreams.Test() + ios.SetStdinTTY(tt.tty) + ios.SetStdoutTTY(tt.tty) + f := &cmdutil.Factory{ + IOStreams: ios, + } + argv, err := shlex.Split(tt.input) + assert.NoError(t, err) + + var cmdOpts *DeleteOptions + cmd := NewCmdDelete(f, func(opts *DeleteOptions) error { + cmdOpts = opts + return nil + }) + cmd.SetArgs(argv) + cmd.SetIn(&bytes.Buffer{}) + cmd.SetOut(&bytes.Buffer{}) + cmd.SetErr(&bytes.Buffer{}) + + _, err = cmd.ExecuteC() + if tt.wantErr { + assert.Error(t, err) + assert.EqualError(t, err, tt.wantErrMsg) + return + } + assert.NoError(t, err) + assert.Equal(t, tt.output.KeyID, cmdOpts.KeyID) + assert.Equal(t, tt.output.Confirmed, cmdOpts.Confirmed) + }) + } +} + +func Test_deleteRun(t *testing.T) { + keysResp := "[{\"id\":123,\"key_id\":\"ABC123\"}]" + tests := []struct { + name string + tty bool + opts DeleteOptions + httpStubs func(*httpmock.Registry) + prompterStubs func(*prompter.PrompterMock) + wantStdout string + wantErr bool + wantErrMsg string + }{ + { + name: "delete tty", + tty: true, + opts: DeleteOptions{KeyID: "ABC123", Confirmed: false}, + prompterStubs: func(pm *prompter.PrompterMock) { + pm.ConfirmDeletionFunc = func(_ string) error { + return nil + } + }, + httpStubs: func(reg *httpmock.Registry) { + reg.Register(httpmock.REST("GET", "user/gpg_keys"), httpmock.StatusStringResponse(200, keysResp)) + reg.Register(httpmock.REST("DELETE", "user/gpg_keys/123"), httpmock.StatusStringResponse(204, "")) + }, + wantStdout: "✓ GPG key ABC123 deleted from your account\n", + }, + { + name: "delete with confirm flag tty", + tty: true, + opts: DeleteOptions{KeyID: "ABC123", Confirmed: true}, + httpStubs: func(reg *httpmock.Registry) { + reg.Register(httpmock.REST("GET", "user/gpg_keys"), httpmock.StatusStringResponse(200, keysResp)) + reg.Register(httpmock.REST("DELETE", "user/gpg_keys/123"), httpmock.StatusStringResponse(204, "")) + }, + wantStdout: "✓ GPG key ABC123 deleted from your account\n", + }, + { + name: "not found tty", + tty: true, + opts: DeleteOptions{KeyID: "ABC123", Confirmed: true}, + httpStubs: func(reg *httpmock.Registry) { + reg.Register(httpmock.REST("GET", "user/gpg_keys"), httpmock.StatusStringResponse(200, "[]")) + }, + wantErr: true, + wantErrMsg: "unable to delete GPG key ABC123: either the GPG key is not found or it is not owned by you", + }, + { + name: "delete no tty", + opts: DeleteOptions{KeyID: "ABC123", Confirmed: true}, + httpStubs: func(reg *httpmock.Registry) { + reg.Register(httpmock.REST("GET", "user/gpg_keys"), httpmock.StatusStringResponse(200, keysResp)) + reg.Register(httpmock.REST("DELETE", "user/gpg_keys/123"), httpmock.StatusStringResponse(204, "")) + }, + wantStdout: "", + }, + { + name: "not found no tty", + opts: DeleteOptions{KeyID: "ABC123", Confirmed: true}, + httpStubs: func(reg *httpmock.Registry) { + reg.Register(httpmock.REST("GET", "user/gpg_keys"), httpmock.StatusStringResponse(200, "[]")) + }, + wantErr: true, + wantErrMsg: "unable to delete GPG key ABC123: either the GPG key is not found or it is not owned by you", + }, + } + + for _, tt := range tests { + pm := &prompter.PrompterMock{} + if tt.prompterStubs != nil { + tt.prompterStubs(pm) + } + tt.opts.Prompter = pm + + reg := &httpmock.Registry{} + if tt.httpStubs != nil { + tt.httpStubs(reg) + } + + tt.opts.HttpClient = func() (*http.Client, error) { + return &http.Client{Transport: reg}, nil + } + tt.opts.Config = func() (config.Config, error) { + return config.NewBlankConfig(), nil + } + ios, _, stdout, _ := iostreams.Test() + ios.SetStdinTTY(tt.tty) + ios.SetStdoutTTY(tt.tty) + tt.opts.IO = ios + + t.Run(tt.name, func(t *testing.T) { + err := deleteRun(&tt.opts) + reg.Verify(t) + if tt.wantErr { + assert.Error(t, err) + assert.EqualError(t, err, tt.wantErrMsg) + return + } + assert.NoError(t, err) + assert.Equal(t, tt.wantStdout, stdout.String()) + }) + } +} diff --git a/pkg/cmd/gpg-key/delete/http.go b/pkg/cmd/gpg-key/delete/http.go new file mode 100644 index 000000000..cfb11b9c3 --- /dev/null +++ b/pkg/cmd/gpg-key/delete/http.go @@ -0,0 +1,68 @@ +package delete + +import ( + "encoding/json" + "fmt" + "io" + "net/http" + + "github.com/cli/cli/v2/api" + "github.com/cli/cli/v2/internal/ghinstance" +) + +type gpgKey struct { + ID int + KeyID string `json:"key_id"` +} + +func deleteGPGKey(httpClient *http.Client, host, id string) error { + url := fmt.Sprintf("%suser/gpg_keys/%s", ghinstance.RESTPrefix(host), id) + req, err := http.NewRequest("DELETE", url, nil) + if err != nil { + return err + } + + resp, err := httpClient.Do(req) + if err != nil { + return err + } + defer resp.Body.Close() + + if resp.StatusCode > 299 { + return api.HandleHTTPError(resp) + } + + return nil +} + +func getGPGKeys(httpClient *http.Client, host string) ([]gpgKey, error) { + resource := "user/gpg_keys" + url := fmt.Sprintf("%s%s?per_page=%d", ghinstance.RESTPrefix(host), resource, 100) + req, err := http.NewRequest("GET", url, nil) + if err != nil { + return nil, err + } + + resp, err := httpClient.Do(req) + if err != nil { + return nil, err + } + defer resp.Body.Close() + + if resp.StatusCode > 299 { + return nil, api.HandleHTTPError(resp) + } + + b, err := io.ReadAll(resp.Body) + if err != nil { + return nil, err + } + + var keys []gpgKey + err = json.Unmarshal(b, &keys) + if err != nil { + return nil, err + } + + return keys, nil +} diff --git a/pkg/cmd/gpg-key/gpg_key.go b/pkg/cmd/gpg-key/gpg_key.go index 66245208f..b1551c0b1 100644 --- a/pkg/cmd/gpg-key/gpg_key.go +++ b/pkg/cmd/gpg-key/gpg_key.go @@ -2,6 +2,7 @@ package key import ( cmdAdd "github.com/cli/cli/v2/pkg/cmd/gpg-key/add" + cmdDelete "github.com/cli/cli/v2/pkg/cmd/gpg-key/delete" cmdList "github.com/cli/cli/v2/pkg/cmd/gpg-key/list" "github.com/cli/cli/v2/pkg/cmdutil" "github.com/spf13/cobra" @@ -14,8 +15,9 @@ func NewCmdGPGKey(f *cmdutil.Factory) *cobra.Command { Long: "Manage GPG keys registered with your GitHub account.", } - cmd.AddCommand(cmdList.NewCmdList(f, nil)) cmd.AddCommand(cmdAdd.NewCmdAdd(f, nil)) + cmd.AddCommand(cmdDelete.NewCmdDelete(f, nil)) + cmd.AddCommand(cmdList.NewCmdList(f, nil)) return cmd } From 3fe5026d39f1cb4dacec3793ef45bbe2f416c987 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mislav=20Marohni=C4=87?= Date: Mon, 17 Oct 2022 15:15:39 +0200 Subject: [PATCH 34/44] Migrate to tableprinter from go-gh (#6346) --- internal/tableprinter/table_printer.go | 52 ++++++ pkg/cmd/alias/list/list.go | 1 + pkg/cmd/codespace/list.go | 1 + pkg/cmd/codespace/ports.go | 1 + pkg/cmd/extension/command.go | 1 + pkg/cmd/gist/list/list.go | 1 + pkg/cmd/gpg-key/list/list.go | 1 + pkg/cmd/issue/shared/display.go | 1 + pkg/cmd/label/list.go | 1 + pkg/cmd/pr/checks/output.go | 1 + pkg/cmd/pr/list/list.go | 1 + pkg/cmd/release/list/list.go | 22 +-- pkg/cmd/release/list/list_test.go | 9 +- pkg/cmd/release/view/view.go | 1 + pkg/cmd/repo/deploy-key/list/list.go | 1 + pkg/cmd/repo/list/list.go | 1 + pkg/cmd/run/list/list.go | 1 + pkg/cmd/search/repos/repos.go | 1 + pkg/cmd/search/shared/shared.go | 1 + pkg/cmd/secret/list/list.go | 1 + pkg/cmd/ssh-key/list/list.go | 1 + pkg/cmd/status/status.go | 1 + pkg/cmd/workflow/list/list.go | 1 + pkg/cmd/workflow/view/view.go | 1 + utils/table_printer.go | 218 ++++--------------------- utils/table_printer_test.go | 31 ---- 26 files changed, 115 insertions(+), 238 deletions(-) create mode 100644 internal/tableprinter/table_printer.go delete mode 100644 utils/table_printer_test.go diff --git a/internal/tableprinter/table_printer.go b/internal/tableprinter/table_printer.go new file mode 100644 index 000000000..2e8d398eb --- /dev/null +++ b/internal/tableprinter/table_printer.go @@ -0,0 +1,52 @@ +package tableprinter + +import ( + "strings" + "time" + + "github.com/cli/cli/v2/internal/text" + "github.com/cli/cli/v2/pkg/iostreams" + "github.com/cli/go-gh/pkg/tableprinter" +) + +type TablePrinter struct { + tableprinter.TablePrinter + isTTY bool +} + +func (t *TablePrinter) HeaderRow(columns ...string) { + if !t.isTTY { + return + } + for _, col := range columns { + t.AddField(strings.ToUpper(col)) + } + t.EndRow() +} + +func (tp *TablePrinter) AddTimeField(t time.Time, c func(string) string) { + tf := t.Format(time.RFC3339) + if tp.isTTY { + // TODO: use a static time.Now + tf = text.FuzzyAgo(time.Now(), t) + } + tp.AddField(tf, tableprinter.WithColor(c)) +} + +var ( + WithTruncate = tableprinter.WithTruncate + WithColor = tableprinter.WithColor +) + +func New(ios *iostreams.IOStreams) *TablePrinter { + maxWidth := 80 + isTTY := ios.IsStdoutTTY() + if isTTY { + maxWidth = ios.TerminalWidth() + } + tp := tableprinter.New(ios.Out, isTTY, maxWidth) + return &TablePrinter{ + TablePrinter: tp, + isTTY: isTTY, + } +} diff --git a/pkg/cmd/alias/list/list.go b/pkg/cmd/alias/list/list.go index a7ea40ac4..1039759e3 100644 --- a/pkg/cmd/alias/list/list.go +++ b/pkg/cmd/alias/list/list.go @@ -54,6 +54,7 @@ func listRun(opts *ListOptions) error { return cmdutil.NewNoResultsError("no aliases configured") } + //nolint:staticcheck // SA1019: utils.NewTablePrinter is deprecated: use internal/tableprinter tp := utils.NewTablePrinter(opts.IO) keys := []string{} for alias := range aliasMap { diff --git a/pkg/cmd/codespace/list.go b/pkg/cmd/codespace/list.go index 310f1c658..71a869d77 100644 --- a/pkg/cmd/codespace/list.go +++ b/pkg/cmd/codespace/list.go @@ -86,6 +86,7 @@ func (a *App) List(ctx context.Context, opts *listOptions, exporter cmdutil.Expo return cmdutil.NewNoResultsError("no codespaces found") } + //nolint:staticcheck // SA1019: utils.NewTablePrinter is deprecated: use internal/tableprinter tp := utils.NewTablePrinter(a.io) if tp.IsTTY() { tp.AddField("NAME", nil, nil) diff --git a/pkg/cmd/codespace/ports.go b/pkg/cmd/codespace/ports.go index 35d79e8b3..5b55f0c5d 100644 --- a/pkg/cmd/codespace/ports.go +++ b/pkg/cmd/codespace/ports.go @@ -103,6 +103,7 @@ func (a *App) ListPorts(ctx context.Context, codespaceName string, exporter cmdu } cs := a.io.ColorScheme() + //nolint:staticcheck // SA1019: utils.NewTablePrinter is deprecated: use internal/tableprinter tp := utils.NewTablePrinter(a.io) if tp.IsTTY() { diff --git a/pkg/cmd/extension/command.go b/pkg/cmd/extension/command.go index 50112b717..995e4bfd4 100644 --- a/pkg/cmd/extension/command.go +++ b/pkg/cmd/extension/command.go @@ -50,6 +50,7 @@ func NewCmdExtension(f *cmdutil.Factory) *cobra.Command { return cmdutil.NewNoResultsError("no installed extensions found") } cs := io.ColorScheme() + //nolint:staticcheck // SA1019: utils.NewTablePrinter is deprecated: use internal/tableprinter t := utils.NewTablePrinter(io) for _, c := range cmds { var repo string diff --git a/pkg/cmd/gist/list/list.go b/pkg/cmd/gist/list/list.go index bdf936d23..733b0d8f5 100644 --- a/pkg/cmd/gist/list/list.go +++ b/pkg/cmd/gist/list/list.go @@ -95,6 +95,7 @@ func listRun(opts *ListOptions) error { cs := opts.IO.ColorScheme() + //nolint:staticcheck // SA1019: utils.NewTablePrinter is deprecated: use internal/tableprinter tp := utils.NewTablePrinter(opts.IO) for _, gist := range gists { diff --git a/pkg/cmd/gpg-key/list/list.go b/pkg/cmd/gpg-key/list/list.go index 28dfd570e..3a52c64fe 100644 --- a/pkg/cmd/gpg-key/list/list.go +++ b/pkg/cmd/gpg-key/list/list.go @@ -71,6 +71,7 @@ func listRun(opts *ListOptions) error { return cmdutil.NewNoResultsError("no GPG keys present in the GitHub account") } + //nolint:staticcheck // SA1019: utils.NewTablePrinter is deprecated: use internal/tableprinter t := utils.NewTablePrinter(opts.IO) cs := opts.IO.ColorScheme() now := time.Now() diff --git a/pkg/cmd/issue/shared/display.go b/pkg/cmd/issue/shared/display.go index 84b26633a..efb8398ce 100644 --- a/pkg/cmd/issue/shared/display.go +++ b/pkg/cmd/issue/shared/display.go @@ -15,6 +15,7 @@ import ( func PrintIssues(io *iostreams.IOStreams, now time.Time, prefix string, totalCount int, issues []api.Issue) { cs := io.ColorScheme() + //nolint:staticcheck // SA1019: utils.NewTablePrinter is deprecated: use internal/tableprinter table := utils.NewTablePrinter(io) for _, issue := range issues { issueNum := strconv.Itoa(issue.Number) diff --git a/pkg/cmd/label/list.go b/pkg/cmd/label/list.go index 4efec440f..5ad056115 100644 --- a/pkg/cmd/label/list.go +++ b/pkg/cmd/label/list.go @@ -134,6 +134,7 @@ func listRun(opts *listOptions) error { func printLabels(io *iostreams.IOStreams, labels []label) error { cs := io.ColorScheme() + //nolint:staticcheck // SA1019: utils.NewTablePrinter is deprecated: use internal/tableprinter table := utils.NewTablePrinter(io) for _, label := range labels { diff --git a/pkg/cmd/pr/checks/output.go b/pkg/cmd/pr/checks/output.go index d1adb7c54..bf5ecc839 100644 --- a/pkg/cmd/pr/checks/output.go +++ b/pkg/cmd/pr/checks/output.go @@ -76,6 +76,7 @@ func printSummary(io *iostreams.IOStreams, counts checkCounts) { } func printTable(io *iostreams.IOStreams, checks []check) error { + //nolint:staticcheck // SA1019: utils.NewTablePrinter is deprecated: use internal/tableprinter tp := utils.NewTablePrinter(io) sort.Slice(checks, func(i, j int) bool { diff --git a/pkg/cmd/pr/list/list.go b/pkg/cmd/pr/list/list.go index ecf1c4454..a56ac0e5d 100644 --- a/pkg/cmd/pr/list/list.go +++ b/pkg/cmd/pr/list/list.go @@ -198,6 +198,7 @@ func listRun(opts *ListOptions) error { } cs := opts.IO.ColorScheme() + //nolint:staticcheck // SA1019: utils.NewTablePrinter is deprecated: use internal/tableprinter table := utils.NewTablePrinter(opts.IO) for _, pr := range listResult.PullRequests { prNum := strconv.Itoa(pr.Number) diff --git a/pkg/cmd/release/list/list.go b/pkg/cmd/release/list/list.go index ef9f65f94..6d18c80c0 100644 --- a/pkg/cmd/release/list/list.go +++ b/pkg/cmd/release/list/list.go @@ -3,13 +3,12 @@ package list import ( "fmt" "net/http" - "time" "github.com/cli/cli/v2/internal/ghrepo" + "github.com/cli/cli/v2/internal/tableprinter" "github.com/cli/cli/v2/internal/text" "github.com/cli/cli/v2/pkg/cmdutil" "github.com/cli/cli/v2/pkg/iostreams" - "github.com/cli/cli/v2/utils" "github.com/spf13/cobra" ) @@ -76,14 +75,15 @@ func listRun(opts *ListOptions) error { fmt.Fprintf(opts.IO.ErrOut, "failed to start pager: %v\n", err) } - table := utils.NewTablePrinter(opts.IO) + table := tableprinter.New(opts.IO) iofmt := opts.IO.ColorScheme() + table.HeaderRow("Title", "Type", "Tag name", "Published") for _, rel := range releases { title := text.RemoveExcessiveWhitespace(rel.Name) if title == "" { title = rel.TagName } - table.AddField(title, nil, nil) + table.AddField(title) badge := "" var badgeColor func(string) string @@ -97,23 +97,15 @@ func listRun(opts *ListOptions) error { badge = "Pre-release" badgeColor = iofmt.Yellow } - table.AddField(badge, nil, badgeColor) + table.AddField(badge, tableprinter.WithColor(badgeColor)) - tagName := rel.TagName - if table.IsTTY() { - tagName = fmt.Sprintf("(%s)", tagName) - } - table.AddField(tagName, nil, nil) + table.AddField(rel.TagName, tableprinter.WithTruncate(nil)) pubDate := rel.PublishedAt if rel.PublishedAt.IsZero() { pubDate = rel.CreatedAt } - publishedAt := pubDate.Format(time.RFC3339) - if table.IsTTY() { - publishedAt = text.FuzzyAgo(time.Now(), pubDate) - } - table.AddField(publishedAt, nil, iofmt.Gray) + table.AddTimeField(pubDate, iofmt.Gray) table.EndRow() } err = table.Render() diff --git a/pkg/cmd/release/list/list_test.go b/pkg/cmd/release/list/list_test.go index c88185aad..4c4c0868e 100644 --- a/pkg/cmd/release/list/list_test.go +++ b/pkg/cmd/release/list/list_test.go @@ -103,10 +103,11 @@ func Test_listRun(t *testing.T) { LimitResults: 30, }, wantStdout: heredoc.Doc(` - v1.1.0 Draft (v1.1.0) about 1 day ago - The big 1.0 Latest (v1.0.0) about 1 day ago - 1.0 release candidate Pre-release (v1.0.0-pre.2) about 1 day ago - New features (v0.9.2) about 1 day ago + TITLE TYPE TAG NAME PUBLISHED + v1.1.0 Draft v1.1.0 about 1 day ago + The big 1.0 Latest v1.0.0 about 1 day ago + 1.0 release candidate Pre-release v1.0.0-pre.2 about 1 day ago + New features v0.9.2 about 1 day ago `), wantStderr: ``, }, diff --git a/pkg/cmd/release/view/view.go b/pkg/cmd/release/view/view.go index 38cdc6523..b0f130edb 100644 --- a/pkg/cmd/release/view/view.go +++ b/pkg/cmd/release/view/view.go @@ -152,6 +152,7 @@ func renderReleaseTTY(io *iostreams.IOStreams, release *shared.Release) error { if len(release.Assets) > 0 { fmt.Fprintf(w, "%s\n", iofmt.Bold("Assets")) + //nolint:staticcheck // SA1019: utils.NewTablePrinter is deprecated: use internal/tableprinter table := utils.NewTablePrinter(io) for _, a := range release.Assets { table.AddField(a.Name, nil, nil) diff --git a/pkg/cmd/repo/deploy-key/list/list.go b/pkg/cmd/repo/deploy-key/list/list.go index f682582ee..9504b3725 100644 --- a/pkg/cmd/repo/deploy-key/list/list.go +++ b/pkg/cmd/repo/deploy-key/list/list.go @@ -64,6 +64,7 @@ func listRun(opts *ListOptions) error { return cmdutil.NewNoResultsError(fmt.Sprintf("no deploy keys found in %s", ghrepo.FullName(repo))) } + //nolint:staticcheck // SA1019: utils.NewTablePrinter is deprecated: use internal/tableprinter t := utils.NewTablePrinter(opts.IO) cs := opts.IO.ColorScheme() now := time.Now() diff --git a/pkg/cmd/repo/list/list.go b/pkg/cmd/repo/list/list.go index 5a85485b1..a7e21e0ee 100644 --- a/pkg/cmd/repo/list/list.go +++ b/pkg/cmd/repo/list/list.go @@ -164,6 +164,7 @@ func listRun(opts *ListOptions) error { } cs := opts.IO.ColorScheme() + //nolint:staticcheck // SA1019: utils.NewTablePrinter is deprecated: use internal/tableprinter tp := utils.NewTablePrinter(opts.IO) for _, repo := range listResult.Repositories { diff --git a/pkg/cmd/run/list/list.go b/pkg/cmd/run/list/list.go index c68245662..0fe685110 100644 --- a/pkg/cmd/run/list/list.go +++ b/pkg/cmd/run/list/list.go @@ -119,6 +119,7 @@ func listRun(opts *ListOptions) error { return opts.Exporter.Write(opts.IO, runs) } + //nolint:staticcheck // SA1019: utils.NewTablePrinter is deprecated: use internal/tableprinter tp := utils.NewTablePrinter(opts.IO) cs := opts.IO.ColorScheme() diff --git a/pkg/cmd/search/repos/repos.go b/pkg/cmd/search/repos/repos.go index 8cfacf8fa..25c796c37 100644 --- a/pkg/cmd/search/repos/repos.go +++ b/pkg/cmd/search/repos/repos.go @@ -158,6 +158,7 @@ func displayResults(io *iostreams.IOStreams, now time.Time, results search.Repos now = time.Now() } cs := io.ColorScheme() + //nolint:staticcheck // SA1019: utils.NewTablePrinter is deprecated: use internal/tableprinter tp := utils.NewTablePrinter(io) for _, repo := range results.Items { tags := []string{visibilityLabel(repo)} diff --git a/pkg/cmd/search/shared/shared.go b/pkg/cmd/search/shared/shared.go index be163482b..a5cdfe8dc 100644 --- a/pkg/cmd/search/shared/shared.go +++ b/pkg/cmd/search/shared/shared.go @@ -96,6 +96,7 @@ func displayIssueResults(io *iostreams.IOStreams, now time.Time, et EntityType, now = time.Now() } cs := io.ColorScheme() + //nolint:staticcheck // SA1019: utils.NewTablePrinter is deprecated: use internal/tableprinter tp := utils.NewTablePrinter(io) for _, issue := range results.Items { if et == Both { diff --git a/pkg/cmd/secret/list/list.go b/pkg/cmd/secret/list/list.go index a7336570c..4c1a1d01d 100644 --- a/pkg/cmd/secret/list/list.go +++ b/pkg/cmd/secret/list/list.go @@ -146,6 +146,7 @@ func listRun(opts *ListOptions) error { fmt.Fprintf(opts.IO.ErrOut, "failed to start pager: %v\n", err) } + //nolint:staticcheck // SA1019: utils.NewTablePrinter is deprecated: use internal/tableprinter tp := utils.NewTablePrinter(opts.IO) for _, secret := range secrets { tp.AddField(secret.Name, nil, nil) diff --git a/pkg/cmd/ssh-key/list/list.go b/pkg/cmd/ssh-key/list/list.go index 5bde29b9e..376404d42 100644 --- a/pkg/cmd/ssh-key/list/list.go +++ b/pkg/cmd/ssh-key/list/list.go @@ -64,6 +64,7 @@ func listRun(opts *ListOptions) error { return cmdutil.NewNoResultsError("no SSH keys present in the GitHub account") } + //nolint:staticcheck // SA1019: utils.NewTablePrinter is deprecated: use internal/tableprinter t := utils.NewTablePrinter(opts.IO) cs := opts.IO.ColorScheme() now := time.Now() diff --git a/pkg/cmd/status/status.go b/pkg/cmd/status/status.go index 69a53686e..c73db711a 100644 --- a/pkg/cmd/status/status.go +++ b/pkg/cmd/status/status.go @@ -664,6 +664,7 @@ func statusRun(opts *StatusOptions) error { section := func(header string, items []StatusItem, width, rowLimit int) (string, error) { tableOut := &bytes.Buffer{} fmt.Fprintln(tableOut, cs.Bold(header)) + //nolint:staticcheck // SA1019: utils.NewTablePrinterWithOptions is deprecated: use internal/tableprinter tp := utils.NewTablePrinterWithOptions(opts.IO, utils.TablePrinterOptions{ IsTTY: opts.IO.IsStdoutTTY(), MaxWidth: width, diff --git a/pkg/cmd/workflow/list/list.go b/pkg/cmd/workflow/list/list.go index e2c897ab8..a18ebd065 100644 --- a/pkg/cmd/workflow/list/list.go +++ b/pkg/cmd/workflow/list/list.go @@ -92,6 +92,7 @@ func listRun(opts *ListOptions) error { fmt.Fprintf(opts.IO.ErrOut, "failed to start pager: %v\n", err) } + //nolint:staticcheck // SA1019: utils.NewTablePrinter is deprecated: use internal/tableprinter tp := utils.NewTablePrinter(opts.IO) cs := opts.IO.ColorScheme() diff --git a/pkg/cmd/workflow/view/view.go b/pkg/cmd/workflow/view/view.go index 7d087c989..4dc4e7658 100644 --- a/pkg/cmd/workflow/view/view.go +++ b/pkg/cmd/workflow/view/view.go @@ -205,6 +205,7 @@ func viewWorkflowInfo(opts *ViewOptions, client *api.Client, repo ghrepo.Interfa out := opts.IO.Out cs := opts.IO.ColorScheme() + //nolint:staticcheck // SA1019: utils.NewTablePrinter is deprecated: use internal/tableprinter tp := utils.NewTablePrinter(opts.IO) // Header diff --git a/utils/table_printer.go b/utils/table_printer.go index 2ccef943c..e4db98bdc 100644 --- a/utils/table_printer.go +++ b/utils/table_printer.go @@ -1,13 +1,11 @@ package utils import ( - "fmt" "io" - "sort" "strings" - "github.com/cli/cli/v2/internal/text" "github.com/cli/cli/v2/pkg/iostreams" + "github.com/cli/go-gh/pkg/tableprinter" ) type TablePrinter interface { @@ -23,12 +21,14 @@ type TablePrinterOptions struct { Out io.Writer } +// Deprecated: use internal/tableprinter func NewTablePrinter(io *iostreams.IOStreams) TablePrinter { return NewTablePrinterWithOptions(io, TablePrinterOptions{ IsTTY: io.IsStdoutTTY(), }) } +// Deprecated: use internal/tableprinter func NewTablePrinterWithOptions(ios *iostreams.IOStreams, opts TablePrinterOptions) TablePrinter { var out io.Writer if opts.Out != nil { @@ -36,8 +36,8 @@ func NewTablePrinterWithOptions(ios *iostreams.IOStreams, opts TablePrinterOptio } else { out = ios.Out } + var maxWidth int if opts.IsTTY { - var maxWidth int if opts.MaxWidth > 0 { maxWidth = opts.MaxWidth } else if ios.IsStdoutTTY() { @@ -45,205 +45,47 @@ func NewTablePrinterWithOptions(ios *iostreams.IOStreams, opts TablePrinterOptio } else { maxWidth = ios.ProcessTerminalWidth() } - return &ttyTablePrinter{ - out: out, - maxWidth: maxWidth, - } } - return &tsvTablePrinter{ - out: out, + tp := tableprinter.New(out, opts.IsTTY, maxWidth) + return &printer{ + tp: tp, + isTTY: opts.IsTTY, } } -type tableField struct { - Text string - TruncateFunc func(int, string) string - ColorFunc func(string) string +type printer struct { + tp tableprinter.TablePrinter + colIndex int + isTTY bool } -func (f *tableField) DisplayWidth() int { - return text.DisplayWidth(f.Text) +func (p printer) IsTTY() bool { + return p.isTTY } -type ttyTablePrinter struct { - out io.Writer - maxWidth int - rows [][]tableField -} - -func (t ttyTablePrinter) IsTTY() bool { - return true -} - -func (t *ttyTablePrinter) AddField(s string, truncateFunc func(int, string) string, colorFunc func(string) string) { +func (p *printer) AddField(s string, truncateFunc func(int, string) string, colorFunc func(string) string) { if truncateFunc == nil { - truncateFunc = text.Truncate - } - if t.rows == nil { - t.rows = make([][]tableField, 1) - } - rowI := len(t.rows) - 1 - field := tableField{ - Text: s, - TruncateFunc: truncateFunc, - ColorFunc: colorFunc, - } - t.rows[rowI] = append(t.rows[rowI], field) -} - -func (t *ttyTablePrinter) EndRow() { - t.rows = append(t.rows, []tableField{}) -} - -func (t *ttyTablePrinter) Render() error { - if len(t.rows) == 0 { - return nil - } - - delim := " " - numCols := len(t.rows[0]) - colWidths := t.calculateColumnWidths(len(delim)) - - for _, row := range t.rows { - for col, field := range row { - if col > 0 { - _, err := fmt.Fprint(t.out, delim) - if err != nil { - return err - } - } - truncVal := field.TruncateFunc(colWidths[col], field.Text) - if col < numCols-1 { - // pad value with spaces on the right - if padWidth := colWidths[col] - field.DisplayWidth(); padWidth > 0 { - truncVal += strings.Repeat(" ", padWidth) - } - } - if field.ColorFunc != nil { - truncVal = field.ColorFunc(truncVal) - } - _, err := fmt.Fprint(t.out, truncVal) - if err != nil { - return err - } - } - if len(row) > 0 { - _, err := fmt.Fprint(t.out, "\n") - if err != nil { - return err - } + // Disallow ever truncating the 1st colum or any URL value + if p.colIndex == 0 || isURL(s) { + p.tp.AddField(s, tableprinter.WithTruncate(nil), tableprinter.WithColor(colorFunc)) + } else { + p.tp.AddField(s, tableprinter.WithColor(colorFunc)) } + } else { + p.tp.AddField(s, tableprinter.WithTruncate(truncateFunc), tableprinter.WithColor(colorFunc)) } - return nil + p.colIndex++ } -func (t *ttyTablePrinter) calculateColumnWidths(delimSize int) []int { - numCols := len(t.rows[0]) - allColWidths := make([][]int, numCols) - for _, row := range t.rows { - for col, field := range row { - allColWidths[col] = append(allColWidths[col], field.DisplayWidth()) - } - } - - // calculate max & median content width per column - maxColWidths := make([]int, numCols) - // medianColWidth := make([]int, numCols) - for col := 0; col < numCols; col++ { - widths := allColWidths[col] - sort.Ints(widths) - maxColWidths[col] = widths[len(widths)-1] - // medianColWidth[col] = widths[(len(widths)+1)/2] - } - - colWidths := make([]int, numCols) - // never truncate the first column - colWidths[0] = maxColWidths[0] - // never truncate the last column if it contains URLs - if strings.HasPrefix(t.rows[0][numCols-1].Text, "https://") { - colWidths[numCols-1] = maxColWidths[numCols-1] - } - - availWidth := func() int { - setWidths := 0 - for col := 0; col < numCols; col++ { - setWidths += colWidths[col] - } - return t.maxWidth - delimSize*(numCols-1) - setWidths - } - numFixedCols := func() int { - fixedCols := 0 - for col := 0; col < numCols; col++ { - if colWidths[col] > 0 { - fixedCols++ - } - } - return fixedCols - } - - // set the widths of short columns - if w := availWidth(); w > 0 { - if numFlexColumns := numCols - numFixedCols(); numFlexColumns > 0 { - perColumn := w / numFlexColumns - for col := 0; col < numCols; col++ { - if max := maxColWidths[col]; max < perColumn { - colWidths[col] = max - } - } - } - } - - firstFlexCol := -1 - // truncate long columns to the remaining available width - if numFlexColumns := numCols - numFixedCols(); numFlexColumns > 0 { - perColumn := availWidth() / numFlexColumns - for col := 0; col < numCols; col++ { - if colWidths[col] == 0 { - if firstFlexCol == -1 { - firstFlexCol = col - } - if max := maxColWidths[col]; max < perColumn { - colWidths[col] = max - } else if perColumn > 0 { - colWidths[col] = perColumn - } - } - } - } - - // add remainder to the first flex column - if w := availWidth(); w > 0 && firstFlexCol > -1 { - colWidths[firstFlexCol] += w - if max := maxColWidths[firstFlexCol]; max < colWidths[firstFlexCol] { - colWidths[firstFlexCol] = max - } - } - - return colWidths +func (p *printer) EndRow() { + p.tp.EndRow() + p.colIndex = 0 } -type tsvTablePrinter struct { - out io.Writer - currentCol int +func (p *printer) Render() error { + return p.tp.Render() } -func (t tsvTablePrinter) IsTTY() bool { - return false -} - -func (t *tsvTablePrinter) AddField(text string, _ func(int, string) string, _ func(string) string) { - if t.currentCol > 0 { - fmt.Fprint(t.out, "\t") - } - fmt.Fprint(t.out, text) - t.currentCol++ -} - -func (t *tsvTablePrinter) EndRow() { - fmt.Fprint(t.out, "\n") - t.currentCol = 0 -} - -func (t *tsvTablePrinter) Render() error { - return nil +func isURL(s string) bool { + return strings.HasPrefix(s, "https://") || strings.HasPrefix(s, "http://") } diff --git a/utils/table_printer_test.go b/utils/table_printer_test.go deleted file mode 100644 index 2e3e4e803..000000000 --- a/utils/table_printer_test.go +++ /dev/null @@ -1,31 +0,0 @@ -package utils - -import ( - "bytes" - "testing" -) - -func Test_ttyTablePrinter_truncate(t *testing.T) { - buf := bytes.Buffer{} - tp := &ttyTablePrinter{ - out: &buf, - maxWidth: 5, - } - - tp.AddField("1", nil, nil) - tp.AddField("hello", nil, nil) - tp.EndRow() - tp.AddField("2", nil, nil) - tp.AddField("world", nil, nil) - tp.EndRow() - - err := tp.Render() - if err != nil { - t.Fatalf("unexpected error: %v", err) - } - - expected := "1 he\n2 wo\n" - if buf.String() != expected { - t.Errorf("expected: %q, got: %q", expected, buf.String()) - } -} From d41efa7a6a1f4e08ac8ec03e84a8d09e51368597 Mon Sep 17 00:00:00 2001 From: tdakkota Date: Tue, 18 Oct 2022 09:36:52 +0300 Subject: [PATCH 35/44] Fix #6452 (#6453) --- git/client.go | 6 +++++- git/client_test.go | 34 ++++++++++++++++++++++++++++++++++ 2 files changed, 39 insertions(+), 1 deletion(-) diff --git a/git/client.go b/git/client.go index 7d8686f17..c6bfc21ac 100644 --- a/git/client.go +++ b/git/client.go @@ -163,7 +163,11 @@ func (c *Client) Remotes(ctx context.Context) (RemoteSet, error) { } configOut, configErr := configCmd.Output() if configErr != nil { - return nil, &GitError{err: configErr} + // Ignore exit code 1 as it means there are no resolved remotes. + var exitErr *exec.ExitError + if errors.As(configErr, &exitErr) && exitErr.ExitCode() != 1 { + return nil, &GitError{err: configErr} + } } remotes := parseRemotes(outputLines(remoteOut)) diff --git a/git/client_test.go b/git/client_test.go index d8cff126b..121231d23 100644 --- a/git/client_test.go +++ b/git/client_test.go @@ -122,6 +122,40 @@ func TestClientRemotes(t *testing.T) { assert.Equal(t, "other", rs[3].Resolved) } +func TestClientRemotesNoResolvedRemote(t *testing.T) { + tempDir := t.TempDir() + initRepo(t, tempDir) + gitDir := filepath.Join(tempDir, ".git") + remoteFile := filepath.Join(gitDir, "config") + remotes := ` +[remote "origin"] + url = git@example.com:monalisa/origin.git +[remote "test"] + url = git://github.com/hubot/test.git +[remote "upstream"] + url = https://github.com/monalisa/upstream.git +[remote "github"] + url = git@github.com:hubot/github.git +` + f, err := os.OpenFile(remoteFile, os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0755) + assert.NoError(t, err) + _, err = f.Write([]byte(remotes)) + assert.NoError(t, err) + err = f.Close() + assert.NoError(t, err) + client := Client{ + RepoDir: tempDir, + } + rs, err := client.Remotes(context.Background()) + assert.NoError(t, err) + assert.Equal(t, 4, len(rs)) + assert.Equal(t, "upstream", rs[0].Name) + assert.Equal(t, "github", rs[1].Name) + assert.Equal(t, "origin", rs[2].Name) + assert.Equal(t, "", rs[2].Resolved) + assert.Equal(t, "test", rs[3].Name) +} + func TestParseRemotes(t *testing.T) { remoteList := []string{ "mona\tgit@github.com:monalisa/myfork.git (fetch)", From ee9d7bef0a6048b8bfeadbb3d544548f13e68eaf Mon Sep 17 00:00:00 2001 From: Sam Coe Date: Tue, 18 Oct 2022 11:03:13 +0300 Subject: [PATCH 36/44] Pull in latest go-gh release (#6454) --- go.mod | 10 +++++----- go.sum | 26 ++++++++++++-------------- 2 files changed, 17 insertions(+), 19 deletions(-) diff --git a/go.mod b/go.mod index 5b41c5898..cc2bddc98 100644 --- a/go.mod +++ b/go.mod @@ -6,9 +6,9 @@ require ( github.com/AlecAivazis/survey/v2 v2.3.6 github.com/MakeNowJust/heredoc v1.0.0 github.com/briandowns/spinner v1.18.1 - github.com/charmbracelet/glamour v0.5.0 + github.com/charmbracelet/glamour v0.5.1-0.20220727184942-e70ff2d969da github.com/charmbracelet/lipgloss v0.5.0 - github.com/cli/go-gh v0.1.1-0.20220913125123-04019861008e + github.com/cli/go-gh v0.1.2 github.com/cli/oauth v0.9.0 github.com/cli/safeexec v1.0.0 github.com/cpuguy83/go-md2man/v2 v2.0.2 @@ -47,7 +47,7 @@ require ( github.com/alecthomas/chroma v0.10.0 // indirect github.com/aymerick/douceur v0.2.0 // indirect github.com/cli/browser v1.1.0 // indirect - github.com/cli/shurcooL-graphql v0.0.1 // indirect + github.com/cli/shurcooL-graphql v0.0.2 // indirect github.com/davecgh/go-spew v1.1.1 // indirect github.com/dlclark/regexp2 v1.4.0 // indirect github.com/fatih/color v1.7.0 // indirect @@ -59,7 +59,7 @@ require ( github.com/itchyny/timefmt-go v0.1.3 // indirect github.com/lucasb-eyer/go-colorful v1.2.0 // indirect github.com/mattn/go-runewidth v0.0.13 // indirect - github.com/microcosm-cc/bluemonday v1.0.19 // indirect + github.com/microcosm-cc/bluemonday v1.0.20 // indirect github.com/muesli/reflow v0.3.0 // indirect github.com/olekukonko/tablewriter v0.0.5 // indirect github.com/pmezard/go-difflib v1.0.0 // indirect @@ -70,7 +70,7 @@ require ( github.com/thlib/go-timezone-local v0.0.0-20210907160436-ef149e42d28e // indirect github.com/yuin/goldmark v1.4.4 // indirect github.com/yuin/goldmark-emoji v1.0.1 // indirect - golang.org/x/net v0.0.0-20220722155237-a158d28d115b // indirect + golang.org/x/net v0.0.0-20220923203811-8be639271d50 // indirect golang.org/x/oauth2 v0.0.0-20220309155454-6242fa91716a // indirect google.golang.org/genproto v0.0.0-20200825200019-8632dd797987 // indirect gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15 // indirect diff --git a/go.sum b/go.sum index eb57c19b6..e15581064 100644 --- a/go.sum +++ b/go.sum @@ -46,8 +46,8 @@ github.com/aymerick/douceur v0.2.0/go.mod h1:wlT5vV2O3h55X9m7iVYN0TBM0NH/MmbLnd3 github.com/briandowns/spinner v1.18.1 h1:yhQmQtM1zsqFsouh09Bk/jCjd50pC3EOGsh28gLVvwY= github.com/briandowns/spinner v1.18.1/go.mod h1:mQak9GHqbspjC/5iUx3qMlIho8xBS/ppAL/hX5SmPJU= github.com/census-instrumentation/opencensus-proto v0.2.1/go.mod h1:f6KPmirojxKA12rnyqOA5BBL4O983OfeGPqjHWSTneU= -github.com/charmbracelet/glamour v0.5.0 h1:wu15ykPdB7X6chxugG/NNfDUbyyrCLV9XBalj5wdu3g= -github.com/charmbracelet/glamour v0.5.0/go.mod h1:9ZRtG19AUIzcTm7FGLGbq3D5WKQ5UyZBbQsMQN0XIqc= +github.com/charmbracelet/glamour v0.5.1-0.20220727184942-e70ff2d969da h1:FGz53GWQRiKQ/5xUsoCCkewSQIC7u81Scaxx2nUy3nM= +github.com/charmbracelet/glamour v0.5.1-0.20220727184942-e70ff2d969da/go.mod h1:HXz79SMFnF9arKxqeoHWxmo1BhplAH7wehlRhKQIL94= github.com/charmbracelet/lipgloss v0.5.0 h1:lulQHuVeodSgDez+3rGiuxlPVXSnhth442DATR2/8t8= github.com/charmbracelet/lipgloss v0.5.0/go.mod h1:EZLha/HbzEt7cYqdFPovlqy5FZPj0xFhg5SaqxScmgs= github.com/chzyer/logex v1.1.10/go.mod h1:+Ywpsq7O8HXn0nuIou7OrIPyXbp3wmkHB+jjWRnGsAI= @@ -58,14 +58,14 @@ github.com/cli/browser v1.1.0 h1:xOZBfkfY9L9vMBgqb1YwRirGu6QFaQ5dP/vXt5ENSOY= github.com/cli/browser v1.1.0/go.mod h1:HKMQAt9t12kov91Mn7RfZxyJQQgWgyS/3SZswlZ5iTI= github.com/cli/crypto v0.0.0-20210929142629-6be313f59b03 h1:3f4uHLfWx4/WlnMPXGai03eoWAI+oGHJwr+5OXfxCr8= github.com/cli/crypto v0.0.0-20210929142629-6be313f59b03/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc= -github.com/cli/go-gh v0.1.1-0.20220913125123-04019861008e h1:zK2hqxSk5D/Jt4o+0NVH/qdEFh7fUhgGkhbukwPMzQU= -github.com/cli/go-gh v0.1.1-0.20220913125123-04019861008e/go.mod h1:UKRuMl3ZaitTvO4LPWj5bVw7QwZHnLu0S0lI9WWbdpc= +github.com/cli/go-gh v0.1.2 h1:DoiHIo7uuK51Tw5dmawHfIMcBq9CsNNZ2uQTPkP4pLU= +github.com/cli/go-gh v0.1.2/go.mod h1:bqxLdCoTZ73BuiPEJx4olcO/XKhVZaFDchFagYRBweE= github.com/cli/oauth v0.9.0 h1:nxBC0Df4tUzMkqffAB+uZvisOwT3/N9FpkfdTDtafxc= github.com/cli/oauth v0.9.0/go.mod h1:qd/FX8ZBD6n1sVNQO3aIdRxeu5LGw9WhKnYhIIoC2A4= github.com/cli/safeexec v1.0.0 h1:0VngyaIyqACHdcMNWfo6+KdUYnqEr2Sg+bSP1pdF+dI= github.com/cli/safeexec v1.0.0/go.mod h1:Z/D4tTN8Vs5gXYHDCbaM1S/anmEDnJb1iW0+EJ5zx3Q= -github.com/cli/shurcooL-graphql v0.0.1 h1:/9J3t9O6p1B8zdBBtQighq5g7DQRItBwuwGh3SocsKM= -github.com/cli/shurcooL-graphql v0.0.1/go.mod h1:U7gCSuMZP/Qy7kbqkk5PrqXEeDgtfG5K+W+u8weorps= +github.com/cli/shurcooL-graphql v0.0.2 h1:rwP5/qQQ2fM0TzkUTwtt6E2LbIYf6R+39cUXTa04NYk= +github.com/cli/shurcooL-graphql v0.0.2/go.mod h1:tlrLmw/n5Q/+4qSvosT+9/W5zc8ZMjnJeYBxSdb4nWA= github.com/client9/misspell v0.3.4/go.mod h1:qj6jICC3Q7zFZvVWo7KLAzC3yx5G7kyvSDkc90ppPyw= github.com/cncf/udpa/go v0.0.0-20191209042840-269d4d468f6f/go.mod h1:M8M6+tZqaGXZJjfX53e64911xZQV5JYwmTeXPW+k8Sc= github.com/cpuguy83/go-md2man/v2 v2.0.2 h1:p1EgwI/C7NhT0JmVkwCD2ZBK8j4aeHQX2pMHHBfMQ6w= @@ -186,7 +186,6 @@ github.com/mattn/go-colorable v0.1.2/go.mod h1:U0ppj6V5qS13XJ6of8GYAs25YV2eR4EVc github.com/mattn/go-colorable v0.1.13 h1:fFA4WZxdEF4tXPZVKMLwD8oUnCTTo08duU7wxecdEvA= github.com/mattn/go-colorable v0.1.13/go.mod h1:7S9/ev0klgBDR4GtXTXX8a3vIGJpMovkB8vQcUbaXHg= github.com/mattn/go-isatty v0.0.8/go.mod h1:Iq45c/XA43vh69/j3iqttzPXn0bhXyGjM0Hdxcsrc5s= -github.com/mattn/go-isatty v0.0.13/go.mod h1:cbi8OIDigv2wuxKPP5vlRcQ1OAZbq2CE4Kysco4FUpU= github.com/mattn/go-isatty v0.0.14/go.mod h1:7GGIvUiUoEMVVmxf/4nioHXj79iQHKdU27kJ6hsGG94= github.com/mattn/go-isatty v0.0.16 h1:bq3VjFmv/sOjHtdEhmkEV4x1AJtvUvOJ2PFAZ5+peKQ= github.com/mattn/go-isatty v0.0.16/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/yFXSvRLM= @@ -198,13 +197,13 @@ github.com/mattn/go-runewidth v0.0.13/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh github.com/mgutz/ansi v0.0.0-20170206155736-9520e82c474b/go.mod h1:01TrycV0kFyexm33Z7vhZRXopbI8J3TDReVlkTgMUxE= github.com/mgutz/ansi v0.0.0-20200706080929-d51e80ef957d h1:5PJl274Y63IEHC+7izoQE9x6ikvDFZS2mDVS3drnohI= github.com/mgutz/ansi v0.0.0-20200706080929-d51e80ef957d/go.mod h1:01TrycV0kFyexm33Z7vhZRXopbI8J3TDReVlkTgMUxE= -github.com/microcosm-cc/bluemonday v1.0.17/go.mod h1:Z0r70sCuXHig8YpBzCc5eGHAap2K7e/u082ZUpDRRqM= -github.com/microcosm-cc/bluemonday v1.0.19 h1:OI7hoF5FY4pFz2VA//RN8TfM0YJ2dJcl4P4APrCWy6c= github.com/microcosm-cc/bluemonday v1.0.19/go.mod h1:QNzV2UbLK2/53oIIwTOyLUSABMkjZ4tqiyC1g/DyqxE= +github.com/microcosm-cc/bluemonday v1.0.20 h1:flpzsq4KU3QIYAYGV/szUat7H+GPOXR0B2JU5A1Wp8Y= +github.com/microcosm-cc/bluemonday v1.0.20/go.mod h1:yfBmMi8mxvaZut3Yytv+jTXRY8mxyjJ0/kQBTElld50= github.com/muesli/reflow v0.2.1-0.20210115123740-9e1d0d53df68/go.mod h1:Xk+z4oIWdQqJzsxyjgl3P22oYZnHdZ8FFTHAQQt5BMQ= github.com/muesli/reflow v0.3.0 h1:IFsN6K9NfGtjeggFP+68I4chLZV2yIKsXJFNZ+eWh6s= github.com/muesli/reflow v0.3.0/go.mod h1:pbwTDkVPibjO2kyvBQRBxTWEEGDGq0FlB1BIKtnHY/8= -github.com/muesli/termenv v0.9.0/go.mod h1:R/LzAKf+suGs4IsO95y7+7DpFHO0KABgnZqtlyx2mBw= +github.com/muesli/termenv v0.11.0/go.mod h1:Bd5NYQ7pd+SrtBSrSNoBBmXlcY8+Xj4BMJgh8qcZrvs= github.com/muesli/termenv v0.11.1-0.20220204035834-5ac8409525e0/go.mod h1:Bd5NYQ7pd+SrtBSrSNoBBmXlcY8+Xj4BMJgh8qcZrvs= github.com/muesli/termenv v0.12.0 h1:KuQRUE3PgxRFWhq4gHvZtPSLCGDqM5q/cYr1pZ39ytc= github.com/muesli/termenv v0.12.0/go.mod h1:WCCv32tusQ/EEZ5S8oUIIrC/nIuBcxCVqlN4Xfkv+7A= @@ -314,11 +313,10 @@ golang.org/x/net v0.0.0-20200707034311-ab3426394381/go.mod h1:/O7V0waA8r7cgGh81R golang.org/x/net v0.0.0-20200822124328-c89045814202/go.mod h1:/O7V0waA8r7cgGh81Ro3o1hOxt32SMVPicZroKQ2sZA= golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg= golang.org/x/net v0.0.0-20210614182718-04defd469f4e/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y= -golang.org/x/net v0.0.0-20211112202133-69e39bad7dc2/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y= golang.org/x/net v0.0.0-20220127200216-cd36cc0744dd/go.mod h1:CfG3xpIq0wQ8r1q4Su4UZFWDARRcnwPjda9FqA0JpMk= golang.org/x/net v0.0.0-20220624214902-1bab6f366d9e/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c= -golang.org/x/net v0.0.0-20220722155237-a158d28d115b h1:PxfKdU9lEEDYjdIzOtC4qFWgkU2rGHdKlKowJSMN9h0= -golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c= +golang.org/x/net v0.0.0-20220923203811-8be639271d50 h1:vKyz8L3zkd+xrMeIaBsQ/MNVPVFSffdaU3ZyYlBGFnI= +golang.org/x/net v0.0.0-20220923203811-8be639271d50/go.mod h1:YDH+HFinaLZZlnHAfSS6ZXJJ9M9t4Dl22yv3iI2vPwk= golang.org/x/oauth2 v0.0.0-20180821212333-d2e6202438be/go.mod h1:N/0e6XlmueqKjAGxoOufVs8QHGRruUQn6yWY3a++T0U= golang.org/x/oauth2 v0.0.0-20190226205417-e64efc72b421/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw= golang.org/x/oauth2 v0.0.0-20190604053449-0f29369cfe45/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw= @@ -350,7 +348,6 @@ golang.org/x/sys v0.0.0-20191001151750-bb3f8db39f24/go.mod h1:h1NjWce9XRLGQEsW7w golang.org/x/sys v0.0.0-20191204072324-ce4227a45e2e/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20191228213918-04cbcbbfeed8/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20200113162924-86b910548bc1/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/sys v0.0.0-20200116001909-b77594299b42/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20200122134326-e047566fdf82/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20200202164722-d101bd2416d5/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20200212091648-12a6c2dcc1e4/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= @@ -373,6 +370,7 @@ golang.org/x/sys v0.0.0-20211216021012-1d35b9e2eb4e/go.mod h1:oPkhp1MJrh7nUepCBc golang.org/x/sys v0.0.0-20220209214540-3681064d5158/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20220422013727-9388b58f7150/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20220728004956-3c1f35247d10/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab h1:2QkjZIsXupsJbJIdSjjUOgWK3aEtzyuh2mPt3l/CkeU= golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= From e523d50176059edc1f398142be01133f8c169792 Mon Sep 17 00:00:00 2001 From: Seito Tanaka Date: Tue, 18 Oct 2022 17:10:12 +0900 Subject: [PATCH 37/44] Add support for editing last issue/pr comment (#6384) --- api/queries_comments.go | 48 +++++++++++++- api/queries_issue.go | 4 ++ api/queries_pr.go | 4 ++ api/queries_pr_review.go | 5 ++ api/query_builder.go | 5 +- pkg/cmd/issue/comment/comment.go | 7 +- pkg/cmd/issue/comment/comment_test.go | 93 ++++++++++++++++++++++---- pkg/cmd/pr/comment/comment.go | 7 +- pkg/cmd/pr/comment/comment_test.go | 78 +++++++++++++++++++++- pkg/cmd/pr/shared/commentable.go | 95 +++++++++++++++++++++++---- pkg/cmd/pr/shared/comments.go | 1 + 11 files changed, 318 insertions(+), 29 deletions(-) diff --git a/api/queries_comments.go b/api/queries_comments.go index 8b39675f6..0e62351b1 100644 --- a/api/queries_comments.go +++ b/api/queries_comments.go @@ -15,7 +15,18 @@ type Comments struct { } } +func (cs Comments) CurrentUserComments() []Comment { + var comments []Comment + for _, c := range cs.Nodes { + if c.ViewerDidAuthor { + comments = append(comments, c) + } + } + return comments +} + type Comment struct { + ID string `json:"id"` Author Author `json:"author"` AuthorAssociation string `json:"authorAssociation"` Body string `json:"body"` @@ -24,6 +35,8 @@ type Comment struct { IsMinimized bool `json:"isMinimized"` MinimizedReason string `json:"minimizedReason"` ReactionGroups ReactionGroups `json:"reactionGroups"` + URL string `json:"url,omitempty"` + ViewerDidAuthor bool `json:"viewerDidAuthor"` } type CommentCreateInput struct { @@ -31,6 +44,11 @@ type CommentCreateInput struct { SubjectId string } +type CommentUpdateInput struct { + Body string + CommentId string +} + func CommentCreate(client *Client, repoHost string, params CommentCreateInput) (string, error) { var mutation struct { AddComment struct { @@ -57,6 +75,34 @@ func CommentCreate(client *Client, repoHost string, params CommentCreateInput) ( return mutation.AddComment.CommentEdge.Node.URL, nil } +func CommentUpdate(client *Client, repoHost string, params CommentUpdateInput) (string, error) { + var mutation struct { + UpdateIssueComment struct { + IssueComment struct { + URL string + } + } `graphql:"updateIssueComment(input: $input)"` + } + + variables := map[string]interface{}{ + "input": githubv4.UpdateIssueCommentInput{ + Body: githubv4.String(params.Body), + ID: githubv4.ID(params.CommentId), + }, + } + + err := client.Mutate(repoHost, "CommentUpdate", &mutation, variables) + if err != nil { + return "", err + } + + return mutation.UpdateIssueComment.IssueComment.URL, nil +} + +func (c Comment) Identifier() string { + return c.ID +} + func (c Comment) AuthorLogin() string { return c.Author.Login } @@ -86,7 +132,7 @@ func (c Comment) IsHidden() bool { } func (c Comment) Link() string { - return "" + return c.URL } func (c Comment) Reactions() ReactionGroups { diff --git a/api/queries_issue.go b/api/queries_issue.go index ae409d729..93ef093b7 100644 --- a/api/queries_issue.go +++ b/api/queries_issue.go @@ -244,3 +244,7 @@ func (i Issue) Link() string { func (i Issue) Identifier() string { return i.ID } + +func (i Issue) CurrentUserComments() []Comment { + return i.Comments.CurrentUserComments() +} diff --git a/api/queries_pr.go b/api/queries_pr.go index e4f6013fa..af4e6bb0f 100644 --- a/api/queries_pr.go +++ b/api/queries_pr.go @@ -214,6 +214,10 @@ func (pr PullRequest) Identifier() string { return pr.ID } +func (pr PullRequest) CurrentUserComments() []Comment { + return pr.Comments.CurrentUserComments() +} + func (pr PullRequest) IsOpen() bool { return pr.State == "OPEN" } diff --git a/api/queries_pr_review.go b/api/queries_pr_review.go index edfda99dd..5fcb75e19 100644 --- a/api/queries_pr_review.go +++ b/api/queries_pr_review.go @@ -30,6 +30,7 @@ type PullRequestReviews struct { } type PullRequestReview struct { + ID string `json:"id"` Author Author `json:"author"` AuthorAssociation string `json:"authorAssociation"` Body string `json:"body"` @@ -67,6 +68,10 @@ func AddReview(client *Client, repo ghrepo.Interface, pr *PullRequest, input *Pu return client.Mutate(repo.RepoHost(), "PullRequestReviewAdd", &mutation, variables) } +func (prr PullRequestReview) Identifier() string { + return prr.ID +} + func (prr PullRequestReview) AuthorLogin() string { return prr.Author.Login } diff --git a/api/query_builder.go b/api/query_builder.go index 4d7d27d11..5ed9bcb74 100644 --- a/api/query_builder.go +++ b/api/query_builder.go @@ -23,6 +23,7 @@ func shortenQuery(q string) string { var issueComments = shortenQuery(` comments(first: 100) { nodes { + id, author{login}, authorAssociation, body, @@ -30,7 +31,9 @@ var issueComments = shortenQuery(` includesCreatedEdit, isMinimized, minimizedReason, - reactionGroups{content,users{totalCount}} + reactionGroups{content,users{totalCount}}, + url, + viewerDidAuthor }, pageInfo{hasNextPage,endCursor}, totalCount diff --git a/pkg/cmd/issue/comment/comment.go b/pkg/cmd/issue/comment/comment.go index a3f1b0b4d..3bd9df598 100644 --- a/pkg/cmd/issue/comment/comment.go +++ b/pkg/cmd/issue/comment/comment.go @@ -40,7 +40,11 @@ func NewCmdComment(f *cmdutil.Factory, runF func(*prShared.CommentableOptions) e if err != nil { return nil, nil, err } - return issueShared.IssueFromArgWithFields(httpClient, f.BaseRepo, args[0], []string{"id", "url"}) + fields := []string{"id", "url"} + if opts.EditLast { + fields = append(fields, "comments") + } + return issueShared.IssueFromArgWithFields(httpClient, f.BaseRepo, args[0], fields) } return prShared.CommentablePreRun(cmd, opts) }, @@ -64,6 +68,7 @@ func NewCmdComment(f *cmdutil.Factory, runF func(*prShared.CommentableOptions) e cmd.Flags().StringVarP(&bodyFile, "body-file", "F", "", "Read body text from `file` (use \"-\" to read from standard input)") cmd.Flags().BoolP("editor", "e", false, "Skip prompts and open the text editor to write the body in") cmd.Flags().BoolP("web", "w", false, "Open the web browser to write the comment") + cmd.Flags().BoolVar(&opts.EditLast, "edit-last", false, "Edit the last comment of the same author") return cmd } diff --git a/pkg/cmd/issue/comment/comment_test.go b/pkg/cmd/issue/comment/comment_test.go index edf1900d3..4f747d882 100644 --- a/pkg/cmd/issue/comment/comment_test.go +++ b/pkg/cmd/issue/comment/comment_test.go @@ -8,6 +8,7 @@ import ( "path/filepath" "testing" + "github.com/cli/cli/v2/api" "github.com/cli/cli/v2/internal/browser" "github.com/cli/cli/v2/internal/ghrepo" "github.com/cli/cli/v2/pkg/cmd/pr/shared" @@ -200,7 +201,7 @@ func Test_commentRun(t *testing.T) { InputType: 0, Body: "", - InteractiveEditSurvey: func() (string, error) { return "comment body", nil }, + InteractiveEditSurvey: func(string) (string, error) { return "comment body", nil }, ConfirmSubmitSurvey: func() (bool, error) { return true, nil }, }, httpStubs: func(t *testing.T, reg *httpmock.Registry) { @@ -208,6 +209,22 @@ func Test_commentRun(t *testing.T) { }, stdout: "https://github.com/OWNER/REPO/issues/123#issuecomment-456\n", }, + { + name: "interactive editor with edit last", + input: &shared.CommentableOptions{ + Interactive: true, + InputType: 0, + Body: "", + EditLast: true, + + InteractiveEditSurvey: func(string) (string, error) { return "comment body", nil }, + ConfirmSubmitSurvey: func() (bool, error) { return true, nil }, + }, + httpStubs: func(t *testing.T, reg *httpmock.Registry) { + mockCommentUpdate(t, reg) + }, + stdout: "https://github.com/OWNER/REPO/issues/123#issuecomment-111\n", + }, { name: "non-interactive web", input: &shared.CommentableOptions{ @@ -219,6 +236,18 @@ func Test_commentRun(t *testing.T) { }, stderr: "Opening github.com/OWNER/REPO/issues/123 in your browser.\n", }, + { + name: "non-interactive web with edit last", + input: &shared.CommentableOptions{ + Interactive: false, + InputType: shared.InputTypeWeb, + Body: "", + EditLast: true, + + OpenInBrowser: func(string) error { return nil }, + }, + stderr: "Opening github.com/OWNER/REPO/issues/123 in your browser.\n", + }, { name: "non-interactive editor", input: &shared.CommentableOptions{ @@ -226,13 +255,28 @@ func Test_commentRun(t *testing.T) { InputType: shared.InputTypeEditor, Body: "", - EditSurvey: func() (string, error) { return "comment body", nil }, + EditSurvey: func(string) (string, error) { return "comment body", nil }, }, httpStubs: func(t *testing.T, reg *httpmock.Registry) { mockCommentCreate(t, reg) }, stdout: "https://github.com/OWNER/REPO/issues/123#issuecomment-456\n", }, + { + name: "non-interactive editor with edit last", + input: &shared.CommentableOptions{ + Interactive: false, + InputType: shared.InputTypeEditor, + Body: "", + EditLast: true, + + EditSurvey: func(string) (string, error) { return "comment body", nil }, + }, + httpStubs: func(t *testing.T, reg *httpmock.Registry) { + mockCommentUpdate(t, reg) + }, + stdout: "https://github.com/OWNER/REPO/issues/123#issuecomment-111\n", + }, { name: "non-interactive inline", input: &shared.CommentableOptions{ @@ -245,6 +289,19 @@ func Test_commentRun(t *testing.T) { }, stdout: "https://github.com/OWNER/REPO/issues/123#issuecomment-456\n", }, + { + name: "non-interactive inline with edit last", + input: &shared.CommentableOptions{ + Interactive: false, + InputType: shared.InputTypeInline, + Body: "comment body", + EditLast: true, + }, + httpStubs: func(t *testing.T, reg *httpmock.Registry) { + mockCommentUpdate(t, reg) + }, + stdout: "https://github.com/OWNER/REPO/issues/123#issuecomment-111\n", + }, } for _, tt := range tests { ios, _, stdout, stderr := iostreams.Test() @@ -263,7 +320,14 @@ func Test_commentRun(t *testing.T) { return &http.Client{Transport: reg}, nil } tt.input.RetrieveCommentable = func() (shared.Commentable, ghrepo.Interface, error) { - return &mockCommentable{}, ghrepo.New("OWNER", "REPO"), nil + return &api.Issue{ + ID: "ISSUE-ID", + URL: "https://github.com/OWNER/REPO/issues/123", + Comments: api.Comments{Nodes: []api.Comment{ + {ID: "id1", Author: api.Author{Login: "octocat"}, URL: "https://github.com/OWNER/REPO/issues/123#issuecomment-111", ViewerDidAuthor: true}, + {ID: "id2", Author: api.Author{Login: "monalisa"}, URL: "https://github.com/OWNER/REPO/issues/123#issuecomment-222"}, + }}, + }, ghrepo.New("OWNER", "REPO"), nil } t.Run(tt.name, func(t *testing.T) { @@ -275,15 +339,6 @@ func Test_commentRun(t *testing.T) { } } -type mockCommentable struct{} - -func (c mockCommentable) Identifier() string { - return "ISSUE-ID" -} -func (c mockCommentable) Link() string { - return "https://github.com/OWNER/REPO/issues/123" -} - func mockCommentCreate(t *testing.T, reg *httpmock.Registry) { reg.Register( httpmock.GraphQL(`mutation CommentCreate\b`), @@ -297,3 +352,17 @@ func mockCommentCreate(t *testing.T, reg *httpmock.Registry) { }), ) } + +func mockCommentUpdate(t *testing.T, reg *httpmock.Registry) { + reg.Register( + httpmock.GraphQL(`mutation CommentUpdate\b`), + httpmock.GraphQLMutation(` + { "data": { "updateIssueComment": { "issueComment": { + "url": "https://github.com/OWNER/REPO/issues/123#issuecomment-111" + } } } }`, + func(inputs map[string]interface{}) { + assert.Equal(t, "id1", inputs["id"]) + assert.Equal(t, "comment body", inputs["body"]) + }), + ) +} diff --git a/pkg/cmd/pr/comment/comment.go b/pkg/cmd/pr/comment/comment.go index 1ad79fd11..34644f62b 100644 --- a/pkg/cmd/pr/comment/comment.go +++ b/pkg/cmd/pr/comment/comment.go @@ -41,11 +41,15 @@ func NewCmdComment(f *cmdutil.Factory, runF func(*shared.CommentableOptions) err if len(args) > 0 { selector = args[0] } + fields := []string{"id", "url"} + if opts.EditLast { + fields = append(fields, "comments") + } finder := shared.NewFinder(f) opts.RetrieveCommentable = func() (shared.Commentable, ghrepo.Interface, error) { return finder.Find(shared.FindOptions{ Selector: selector, - Fields: []string{"id", "url"}, + Fields: fields, }) } return shared.CommentablePreRun(cmd, opts) @@ -70,6 +74,7 @@ func NewCmdComment(f *cmdutil.Factory, runF func(*shared.CommentableOptions) err cmd.Flags().StringVarP(&bodyFile, "body-file", "F", "", "Read body text from `file` (use \"-\" to read from standard input)") cmd.Flags().BoolP("editor", "e", false, "Skip prompts and open the text editor to write the body in") cmd.Flags().BoolP("web", "w", false, "Open the web browser to write the comment") + cmd.Flags().BoolVar(&opts.EditLast, "edit-last", false, "Edit the last comment of the same author") return cmd } diff --git a/pkg/cmd/pr/comment/comment_test.go b/pkg/cmd/pr/comment/comment_test.go index a5c0edfb1..f72701859 100644 --- a/pkg/cmd/pr/comment/comment_test.go +++ b/pkg/cmd/pr/comment/comment_test.go @@ -221,7 +221,7 @@ func Test_commentRun(t *testing.T) { InputType: 0, Body: "", - InteractiveEditSurvey: func() (string, error) { return "comment body", nil }, + InteractiveEditSurvey: func(string) (string, error) { return "comment body", nil }, ConfirmSubmitSurvey: func() (bool, error) { return true, nil }, }, httpStubs: func(t *testing.T, reg *httpmock.Registry) { @@ -229,6 +229,22 @@ func Test_commentRun(t *testing.T) { }, stdout: "https://github.com/OWNER/REPO/pull/123#issuecomment-456\n", }, + { + name: "interactive editor with edit last", + input: &shared.CommentableOptions{ + Interactive: true, + InputType: 0, + Body: "", + EditLast: true, + + InteractiveEditSurvey: func(string) (string, error) { return "comment body", nil }, + ConfirmSubmitSurvey: func() (bool, error) { return true, nil }, + }, + httpStubs: func(t *testing.T, reg *httpmock.Registry) { + mockCommentUpdate(t, reg) + }, + stdout: "https://github.com/OWNER/REPO/pull/123#issuecomment-111\n", + }, { name: "non-interactive web", input: &shared.CommentableOptions{ @@ -240,6 +256,18 @@ func Test_commentRun(t *testing.T) { }, stderr: "Opening github.com/OWNER/REPO/pull/123 in your browser.\n", }, + { + name: "non-interactive web with edit last", + input: &shared.CommentableOptions{ + Interactive: false, + InputType: shared.InputTypeWeb, + Body: "", + EditLast: true, + + OpenInBrowser: func(string) error { return nil }, + }, + stderr: "Opening github.com/OWNER/REPO/pull/123 in your browser.\n", + }, { name: "non-interactive editor", input: &shared.CommentableOptions{ @@ -247,13 +275,28 @@ func Test_commentRun(t *testing.T) { InputType: shared.InputTypeEditor, Body: "", - EditSurvey: func() (string, error) { return "comment body", nil }, + EditSurvey: func(string) (string, error) { return "comment body", nil }, }, httpStubs: func(t *testing.T, reg *httpmock.Registry) { mockCommentCreate(t, reg) }, stdout: "https://github.com/OWNER/REPO/pull/123#issuecomment-456\n", }, + { + name: "non-interactive editor with edit last", + input: &shared.CommentableOptions{ + Interactive: false, + InputType: shared.InputTypeEditor, + Body: "", + EditLast: true, + + EditSurvey: func(string) (string, error) { return "comment body", nil }, + }, + httpStubs: func(t *testing.T, reg *httpmock.Registry) { + mockCommentUpdate(t, reg) + }, + stdout: "https://github.com/OWNER/REPO/pull/123#issuecomment-111\n", + }, { name: "non-interactive inline", input: &shared.CommentableOptions{ @@ -266,6 +309,19 @@ func Test_commentRun(t *testing.T) { }, stdout: "https://github.com/OWNER/REPO/pull/123#issuecomment-456\n", }, + { + name: "non-interactive inline with edit last", + input: &shared.CommentableOptions{ + Interactive: false, + InputType: shared.InputTypeInline, + Body: "comment body", + EditLast: true, + }, + httpStubs: func(t *testing.T, reg *httpmock.Registry) { + mockCommentUpdate(t, reg) + }, + stdout: "https://github.com/OWNER/REPO/pull/123#issuecomment-111\n", + }, } for _, tt := range tests { ios, _, stdout, stderr := iostreams.Test() @@ -287,6 +343,10 @@ func Test_commentRun(t *testing.T) { return &api.PullRequest{ Number: 123, URL: "https://github.com/OWNER/REPO/pull/123", + Comments: api.Comments{Nodes: []api.Comment{ + {ID: "id1", Author: api.Author{Login: "octocat"}, URL: "https://github.com/OWNER/REPO/pull/123#issuecomment-111", ViewerDidAuthor: true}, + {ID: "id2", Author: api.Author{Login: "monalisa"}, URL: "https://github.com/OWNER/REPO/pull/123#issuecomment-222"}, + }}, }, ghrepo.New("OWNER", "REPO"), nil } @@ -311,3 +371,17 @@ func mockCommentCreate(t *testing.T, reg *httpmock.Registry) { }), ) } + +func mockCommentUpdate(t *testing.T, reg *httpmock.Registry) { + reg.Register( + httpmock.GraphQL(`mutation CommentUpdate\b`), + httpmock.GraphQLMutation(` + { "data": { "updateIssueComment": { "issueComment": { + "url": "https://github.com/OWNER/REPO/pull/123#issuecomment-111" + } } } }`, + func(inputs map[string]interface{}) { + assert.Equal(t, "id1", inputs["id"]) + assert.Equal(t, "comment body", inputs["body"]) + }), + ) +} diff --git a/pkg/cmd/pr/shared/commentable.go b/pkg/cmd/pr/shared/commentable.go index 5d1037fb7..8f1f89137 100644 --- a/pkg/cmd/pr/shared/commentable.go +++ b/pkg/cmd/pr/shared/commentable.go @@ -29,20 +29,23 @@ const ( type Commentable interface { Link() string Identifier() string + CurrentUserComments() []api.Comment } type CommentableOptions struct { IO *iostreams.IOStreams HttpClient func() (*http.Client, error) RetrieveCommentable func() (Commentable, ghrepo.Interface, error) - EditSurvey func() (string, error) - InteractiveEditSurvey func() (string, error) + EditSurvey func(string) (string, error) + InteractiveEditSurvey func(string) (string, error) ConfirmSubmitSurvey func() (bool, error) OpenInBrowser func(string) error Interactive bool InputType InputType Body string + EditLast bool Quiet bool + Host string } func CommentablePreRun(cmd *cobra.Command, opts *CommentableOptions) error { @@ -81,7 +84,14 @@ func CommentableRun(opts *CommentableOptions) error { if err != nil { return err } + opts.Host = repo.RepoHost() + if opts.EditLast { + return updateComment(commentable, opts) + } + return createComment(commentable, opts) +} +func createComment(commentable Commentable, opts *CommentableOptions) error { switch opts.InputType { case InputTypeWeb: openURL := commentable.Link() + "#issuecomment-new" @@ -91,10 +101,11 @@ func CommentableRun(opts *CommentableOptions) error { return opts.OpenInBrowser(openURL) case InputTypeEditor: var body string + var err error if opts.Interactive { - body, err = opts.InteractiveEditSurvey() + body, err = opts.InteractiveEditSurvey("") } else { - body, err = opts.EditSurvey() + body, err = opts.EditSurvey("") } if err != nil { return err @@ -116,15 +127,77 @@ func CommentableRun(opts *CommentableOptions) error { if err != nil { return err } + apiClient := api.NewClientFromHTTP(httpClient) params := api.CommentCreateInput{Body: opts.Body, SubjectId: commentable.Identifier()} - url, err := api.CommentCreate(apiClient, repo.RepoHost(), params) + url, err := api.CommentCreate(apiClient, opts.Host, params) if err != nil { return err } + if !opts.Quiet { fmt.Fprintln(opts.IO.Out, url) } + + return nil +} + +func updateComment(commentable Commentable, opts *CommentableOptions) error { + comments := commentable.CurrentUserComments() + if len(comments) == 0 { + return fmt.Errorf("no comments found for current user") + } + + lastComment := &comments[len(comments)-1] + + switch opts.InputType { + case InputTypeWeb: + openURL := lastComment.Link() + if opts.IO.IsStdoutTTY() && !opts.Quiet { + fmt.Fprintf(opts.IO.ErrOut, "Opening %s in your browser.\n", text.DisplayURL(openURL)) + } + return opts.OpenInBrowser(openURL) + case InputTypeEditor: + var body string + var err error + initialValue := lastComment.Content() + if opts.Interactive { + body, err = opts.InteractiveEditSurvey(initialValue) + } else { + body, err = opts.EditSurvey(initialValue) + } + if err != nil { + return err + } + opts.Body = body + } + + if opts.Interactive { + cont, err := opts.ConfirmSubmitSurvey() + if err != nil { + return err + } + if !cont { + return errors.New("Discarding...") + } + } + + httpClient, err := opts.HttpClient() + if err != nil { + return err + } + + apiClient := api.NewClientFromHTTP(httpClient) + params := api.CommentUpdateInput{Body: opts.Body, CommentId: lastComment.Identifier()} + url, err := api.CommentUpdate(apiClient, opts.Host, params) + if err != nil { + return err + } + + if !opts.Quiet { + fmt.Fprintln(opts.IO.Out, url) + } + return nil } @@ -138,8 +211,8 @@ func CommentableConfirmSubmitSurvey() (bool, error) { return confirm, err } -func CommentableInteractiveEditSurvey(cf func() (config.Config, error), io *iostreams.IOStreams) func() (string, error) { - return func() (string, error) { +func CommentableInteractiveEditSurvey(cf func() (config.Config, error), io *iostreams.IOStreams) func(string) (string, error) { + return func(initialValue string) (string, error) { editorCommand, err := cmdutil.DetermineEditor(cf) if err != nil { return "", err @@ -147,17 +220,17 @@ func CommentableInteractiveEditSurvey(cf func() (config.Config, error), io *iost cs := io.ColorScheme() fmt.Fprintf(io.Out, "- %s to draft your comment in %s... ", cs.Bold("Press Enter"), cs.Bold(surveyext.EditorName(editorCommand))) _ = waitForEnter(io.In) - return surveyext.Edit(editorCommand, "*.md", "", io.In, io.Out, io.ErrOut) + return surveyext.Edit(editorCommand, "*.md", initialValue, io.In, io.Out, io.ErrOut) } } -func CommentableEditSurvey(cf func() (config.Config, error), io *iostreams.IOStreams) func() (string, error) { - return func() (string, error) { +func CommentableEditSurvey(cf func() (config.Config, error), io *iostreams.IOStreams) func(string) (string, error) { + return func(initialValue string) (string, error) { editorCommand, err := cmdutil.DetermineEditor(cf) if err != nil { return "", err } - return surveyext.Edit(editorCommand, "*.md", "", io.In, io.Out, io.ErrOut) + return surveyext.Edit(editorCommand, "*.md", initialValue, io.In, io.Out, io.ErrOut) } } diff --git a/pkg/cmd/pr/shared/comments.go b/pkg/cmd/pr/shared/comments.go index e7d90a7e6..a05108d7b 100644 --- a/pkg/cmd/pr/shared/comments.go +++ b/pkg/cmd/pr/shared/comments.go @@ -13,6 +13,7 @@ import ( ) type Comment interface { + Identifier() string AuthorLogin() string Association() string Content() string From a756ffb1e82e484e14785f6f6f97f62a88b28f6a Mon Sep 17 00:00:00 2001 From: Ashwin Jeyaseelan <8gitbrix@github.com> Date: Tue, 18 Oct 2022 01:11:28 -0700 Subject: [PATCH 38/44] Fix Codespace Creation Devcontainer bug (#6389) --- internal/codespaces/api/api.go | 3 +- pkg/cmd/codespace/common.go | 2 +- pkg/cmd/codespace/create.go | 6 +-- pkg/cmd/codespace/create_test.go | 85 +++++++++++++++++++++++++++++--- pkg/cmd/codespace/mock_api.go | 46 +++++++++-------- 5 files changed, 110 insertions(+), 32 deletions(-) diff --git a/internal/codespaces/api/api.go b/internal/codespaces/api/api.go index 524c2dcee..c6b2a292f 100644 --- a/internal/codespaces/api/api.go +++ b/internal/codespaces/api/api.go @@ -529,7 +529,7 @@ type Machine struct { } // GetCodespacesMachines returns the codespaces machines for the given repo, branch and location. -func (a *API) GetCodespacesMachines(ctx context.Context, repoID int, branch, location string) ([]*Machine, error) { +func (a *API) GetCodespacesMachines(ctx context.Context, repoID int, branch, location string, devcontainerPath string) ([]*Machine, error) { reqURL := fmt.Sprintf("%s/repositories/%d/codespaces/machines", a.githubAPI, repoID) req, err := http.NewRequest(http.MethodGet, reqURL, nil) if err != nil { @@ -539,6 +539,7 @@ func (a *API) GetCodespacesMachines(ctx context.Context, repoID int, branch, loc q := req.URL.Query() q.Add("location", location) q.Add("ref", branch) + q.Add("devcontainer_path", devcontainerPath) req.URL.RawQuery = q.Encode() a.setHeaders(req) diff --git a/pkg/cmd/codespace/common.go b/pkg/cmd/codespace/common.go index 0cdd69dfa..ee25854dc 100644 --- a/pkg/cmd/codespace/common.go +++ b/pkg/cmd/codespace/common.go @@ -102,7 +102,7 @@ type apiClient interface { CreateCodespace(ctx context.Context, params *api.CreateCodespaceParams) (*api.Codespace, error) EditCodespace(ctx context.Context, codespaceName string, params *api.EditCodespaceParams) (*api.Codespace, error) GetRepository(ctx context.Context, nwo string) (*api.Repository, error) - GetCodespacesMachines(ctx context.Context, repoID int, branch, location string) ([]*api.Machine, error) + GetCodespacesMachines(ctx context.Context, repoID int, branch, location string, devcontainerPath string) ([]*api.Machine, error) GetCodespaceRepositoryContents(ctx context.Context, codespace *api.Codespace, path string) ([]byte, error) ListDevContainers(ctx context.Context, repoID int, branch string, limit int) (devcontainers []api.DevContainerEntry, err error) GetCodespaceRepoSuggestions(ctx context.Context, partialSearch string, params api.RepoSearchParameters) ([]string, error) diff --git a/pkg/cmd/codespace/create.go b/pkg/cmd/codespace/create.go index a6bbe627f..eceb68efb 100644 --- a/pkg/cmd/codespace/create.go +++ b/pkg/cmd/codespace/create.go @@ -225,7 +225,7 @@ func (a *App) Create(ctx context.Context, opts createOptions) error { } } - machine, err := getMachineName(ctx, a.apiClient, repository.ID, opts.machine, branch, userInputs.Location) + machine, err := getMachineName(ctx, a.apiClient, repository.ID, opts.machine, branch, userInputs.Location, devContainerPath) if err != nil { return fmt.Errorf("error getting machine type: %w", err) } @@ -411,8 +411,8 @@ func (a *App) showStatus(ctx context.Context, codespace *api.Codespace) error { } // getMachineName prompts the user to select the machine type, or validates the machine if non-empty. -func getMachineName(ctx context.Context, apiClient apiClient, repoID int, machine, branch, location string) (string, error) { - machines, err := apiClient.GetCodespacesMachines(ctx, repoID, branch, location) +func getMachineName(ctx context.Context, apiClient apiClient, repoID int, machine, branch, location string, devcontainerPath string) (string, error) { + machines, err := apiClient.GetCodespacesMachines(ctx, repoID, branch, location, devcontainerPath) if err != nil { return "", fmt.Errorf("error requesting machine instance types: %w", err) } diff --git a/pkg/cmd/codespace/create_test.go b/pkg/cmd/codespace/create_test.go index cffe5fe43..7892de065 100644 --- a/pkg/cmd/codespace/create_test.go +++ b/pkg/cmd/codespace/create_test.go @@ -45,7 +45,7 @@ func TestApp_Create(t *testing.T) { ListDevContainersFunc: func(ctx context.Context, repoID int, branch string, limit int) ([]api.DevContainerEntry, error) { return []api.DevContainerEntry{{Path: ".devcontainer/devcontainer.json"}}, nil }, - GetCodespacesMachinesFunc: func(ctx context.Context, repoID int, branch, location string) ([]*api.Machine, error) { + GetCodespacesMachinesFunc: func(ctx context.Context, repoID int, branch, location string, devcontainerPath string) ([]*api.Machine, error) { return []*api.Machine{ { Name: "GIGA", @@ -99,7 +99,7 @@ func TestApp_Create(t *testing.T) { Type: "User", }, nil }, - GetCodespacesMachinesFunc: func(ctx context.Context, repoID int, branch, location string) ([]*api.Machine, error) { + GetCodespacesMachinesFunc: func(ctx context.Context, repoID int, branch, location string, devcontainerPath string) ([]*api.Machine, error) { return []*api.Machine{ { Name: "GIGA", @@ -136,6 +136,77 @@ func TestApp_Create(t *testing.T) { }, wantStdout: "monalisa-dotfiles-abcd1234\n", }, + { + name: "create codespace with devcontainer path results in selecting the correct machine type", + fields: fields{ + apiClient: &apiClientMock{ + GetRepositoryFunc: func(ctx context.Context, nwo string) (*api.Repository, error) { + return &api.Repository{ + ID: 1234, + FullName: nwo, + DefaultBranch: "main", + }, nil + }, + GetCodespaceBillableOwnerFunc: func(ctx context.Context, nwo string) (*api.User, error) { + return &api.User{ + Login: "monalisa", + Type: "User", + }, nil + }, + GetCodespacesMachinesFunc: func(ctx context.Context, repoID int, branch, location string, devcontainerPath string) ([]*api.Machine, error) { + if devcontainerPath == "" { + return []*api.Machine{ + { + Name: "GIGA", + DisplayName: "Gigabits of a machine", + }, + }, nil + } else { + return []*api.Machine{ + { + Name: "MEGA", + DisplayName: "Megabits of a machine", + }, + { + Name: "GIGA", + DisplayName: "Gigabits of a machine", + }, + }, nil + } + }, + CreateCodespaceFunc: func(ctx context.Context, params *api.CreateCodespaceParams) (*api.Codespace, error) { + if params.Branch != "main" { + return nil, fmt.Errorf("got branch %q, want %q", params.Branch, "main") + } + if params.IdleTimeoutMinutes != 30 { + return nil, fmt.Errorf("idle timeout minutes was %v", params.IdleTimeoutMinutes) + } + if params.RetentionPeriodMinutes != nil { + return nil, fmt.Errorf("retention period minutes expected nil, was %v", params.RetentionPeriodMinutes) + } + if params.DevContainerPath != ".devcontainer/foobar/devcontainer.json" { + return nil, fmt.Errorf("got dev container path %q, want %q", params.DevContainerPath, ".devcontainer/foobar/devcontainer.json") + } + return &api.Codespace{ + Name: "monalisa-dotfiles-abcd1234", + Machine: api.CodespaceMachine{ + Name: "MEGA", + DisplayName: "Megabits of a machine", + }, + }, nil + }, + }, + }, + opts: createOptions{ + repo: "monalisa/dotfiles", + branch: "", + machine: "MEGA", + showStatus: false, + idleTimeout: 30 * time.Minute, + devContainerPath: ".devcontainer/foobar/devcontainer.json", + }, + wantStdout: "monalisa-dotfiles-abcd1234\n", + }, { name: "create codespace with default branch with default devcontainer if no path provided and no devcontainer files exist in the repo", fields: fields{ @@ -156,7 +227,7 @@ func TestApp_Create(t *testing.T) { ListDevContainersFunc: func(ctx context.Context, repoID int, branch string, limit int) ([]api.DevContainerEntry, error) { return []api.DevContainerEntry{}, nil }, - GetCodespacesMachinesFunc: func(ctx context.Context, repoID int, branch, location string) ([]*api.Machine, error) { + GetCodespacesMachinesFunc: func(ctx context.Context, repoID int, branch, location string, devcontainerPath string) ([]*api.Machine, error) { return []*api.Machine{ { Name: "GIGA", @@ -246,7 +317,7 @@ func TestApp_Create(t *testing.T) { ListDevContainersFunc: func(ctx context.Context, repoID int, branch string, limit int) ([]api.DevContainerEntry, error) { return []api.DevContainerEntry{}, nil }, - GetCodespacesMachinesFunc: func(ctx context.Context, repoID int, branch, location string) ([]*api.Machine, error) { + GetCodespacesMachinesFunc: func(ctx context.Context, repoID int, branch, location string, devcontainerPath string) ([]*api.Machine, error) { return []*api.Machine{ { Name: "GIGA", @@ -302,7 +373,7 @@ func TestApp_Create(t *testing.T) { ListDevContainersFunc: func(ctx context.Context, repoID int, branch string, limit int) ([]api.DevContainerEntry, error) { return []api.DevContainerEntry{{Path: ".devcontainer/devcontainer.json"}}, nil }, - GetCodespacesMachinesFunc: func(ctx context.Context, repoID int, branch, location string) ([]*api.Machine, error) { + GetCodespacesMachinesFunc: func(ctx context.Context, repoID int, branch, location string, devcontainerPath string) ([]*api.Machine, error) { return []*api.Machine{ { Name: "GIGA", @@ -384,7 +455,7 @@ Alternatively, you can run "create" with the "--default-permissions" option to c ListDevContainersFunc: func(ctx context.Context, repoID int, branch string, limit int) ([]api.DevContainerEntry, error) { return []api.DevContainerEntry{{Path: ".devcontainer/devcontainer.json"}}, nil }, - GetCodespacesMachinesFunc: func(ctx context.Context, repoID int, branch, location string) ([]*api.Machine, error) { + GetCodespacesMachinesFunc: func(ctx context.Context, repoID int, branch, location string, devcontainerPath string) ([]*api.Machine, error) { return []*api.Machine{ { Name: "GIGA", @@ -429,7 +500,7 @@ Alternatively, you can run "create" with the "--default-permissions" option to c ListDevContainersFunc: func(ctx context.Context, repoID int, branch string, limit int) ([]api.DevContainerEntry, error) { return []api.DevContainerEntry{{Path: ".devcontainer/devcontainer.json"}}, nil }, - GetCodespacesMachinesFunc: func(ctx context.Context, repoID int, branch, location string) ([]*api.Machine, error) { + GetCodespacesMachinesFunc: func(ctx context.Context, repoID int, branch, location string, devcontainerPath string) ([]*api.Machine, error) { return []*api.Machine{ { Name: "GIGA", diff --git a/pkg/cmd/codespace/mock_api.go b/pkg/cmd/codespace/mock_api.go index 5727591d1..0ce9133ec 100644 --- a/pkg/cmd/codespace/mock_api.go +++ b/pkg/cmd/codespace/mock_api.go @@ -37,7 +37,7 @@ import ( // GetCodespaceRepositoryContentsFunc: func(ctx context.Context, codespace *api.Codespace, path string) ([]byte, error) { // panic("mock out the GetCodespaceRepositoryContents method") // }, -// GetCodespacesMachinesFunc: func(ctx context.Context, repoID int, branch string, location string) ([]*api.Machine, error) { +// GetCodespacesMachinesFunc: func(ctx context.Context, repoID int, branch string, location string, devcontainerPath string) ([]*api.Machine, error) { // panic("mock out the GetCodespacesMachines method") // }, // GetOrgMemberCodespaceFunc: func(ctx context.Context, orgName string, userName string, codespaceName string) (*api.Codespace, error) { @@ -87,7 +87,7 @@ type apiClientMock struct { GetCodespaceRepositoryContentsFunc func(ctx context.Context, codespace *api.Codespace, path string) ([]byte, error) // GetCodespacesMachinesFunc mocks the GetCodespacesMachines method. - GetCodespacesMachinesFunc func(ctx context.Context, repoID int, branch string, location string) ([]*api.Machine, error) + GetCodespacesMachinesFunc func(ctx context.Context, repoID int, branch string, location string, devcontainerPath string) ([]*api.Machine, error) // GetOrgMemberCodespaceFunc mocks the GetOrgMemberCodespace method. GetOrgMemberCodespaceFunc func(ctx context.Context, orgName string, userName string, codespaceName string) (*api.Codespace, error) @@ -180,6 +180,8 @@ type apiClientMock struct { Branch string // Location is the location argument value. Location string + // DevcontainerPath is the devcontainerPath argument value. + DevcontainerPath string } // GetOrgMemberCodespace holds details about calls to the GetOrgMemberCodespace method. GetOrgMemberCodespace []struct { @@ -522,41 +524,45 @@ func (mock *apiClientMock) GetCodespaceRepositoryContentsCalls() []struct { } // GetCodespacesMachines calls GetCodespacesMachinesFunc. -func (mock *apiClientMock) GetCodespacesMachines(ctx context.Context, repoID int, branch string, location string) ([]*api.Machine, error) { +func (mock *apiClientMock) GetCodespacesMachines(ctx context.Context, repoID int, branch string, location string, devcontainerPath string) ([]*api.Machine, error) { if mock.GetCodespacesMachinesFunc == nil { panic("apiClientMock.GetCodespacesMachinesFunc: method is nil but apiClient.GetCodespacesMachines was just called") } callInfo := struct { - Ctx context.Context - RepoID int - Branch string - Location string + Ctx context.Context + RepoID int + Branch string + Location string + DevcontainerPath string }{ - Ctx: ctx, - RepoID: repoID, - Branch: branch, - Location: location, + Ctx: ctx, + RepoID: repoID, + Branch: branch, + Location: location, + DevcontainerPath: devcontainerPath, } mock.lockGetCodespacesMachines.Lock() mock.calls.GetCodespacesMachines = append(mock.calls.GetCodespacesMachines, callInfo) mock.lockGetCodespacesMachines.Unlock() - return mock.GetCodespacesMachinesFunc(ctx, repoID, branch, location) + return mock.GetCodespacesMachinesFunc(ctx, repoID, branch, location, devcontainerPath) } // GetCodespacesMachinesCalls gets all the calls that were made to GetCodespacesMachines. // Check the length with: // len(mockedapiClient.GetCodespacesMachinesCalls()) func (mock *apiClientMock) GetCodespacesMachinesCalls() []struct { - Ctx context.Context - RepoID int - Branch string - Location string + Ctx context.Context + RepoID int + Branch string + Location string + DevcontainerPath string } { var calls []struct { - Ctx context.Context - RepoID int - Branch string - Location string + Ctx context.Context + RepoID int + Branch string + Location string + DevcontainerPath string } mock.lockGetCodespacesMachines.RLock() calls = mock.calls.GetCodespacesMachines From e7270e401df0a786f1fb3e84b49867146f735690 Mon Sep 17 00:00:00 2001 From: ffalor <35144141+ffalor@users.noreply.github.com> Date: Tue, 18 Oct 2022 04:02:53 -0500 Subject: [PATCH 39/44] add `--web` flag to `pr diff` command (#6439) --- pkg/cmd/pr/diff/diff.go | 23 +++++- pkg/cmd/pr/diff/diff_test.go | 133 ++++++++++++++++++++++------------- 2 files changed, 108 insertions(+), 48 deletions(-) diff --git a/pkg/cmd/pr/diff/diff.go b/pkg/cmd/pr/diff/diff.go index b7ef11707..decfeb478 100644 --- a/pkg/cmd/pr/diff/diff.go +++ b/pkg/cmd/pr/diff/diff.go @@ -11,8 +11,10 @@ import ( "github.com/MakeNowJust/heredoc" "github.com/cli/cli/v2/api" + "github.com/cli/cli/v2/internal/browser" "github.com/cli/cli/v2/internal/ghinstance" "github.com/cli/cli/v2/internal/ghrepo" + "github.com/cli/cli/v2/internal/text" "github.com/cli/cli/v2/pkg/cmd/pr/shared" "github.com/cli/cli/v2/pkg/cmdutil" "github.com/cli/cli/v2/pkg/iostreams" @@ -22,6 +24,7 @@ import ( type DiffOptions struct { HttpClient func() (*http.Client, error) IO *iostreams.IOStreams + Browser browser.Browser Finder shared.PRFinder @@ -29,12 +32,14 @@ type DiffOptions struct { UseColor bool Patch bool NameOnly bool + BrowserMode bool } func NewCmdDiff(f *cmdutil.Factory, runF func(*DiffOptions) error) *cobra.Command { opts := &DiffOptions{ IO: f.IOStreams, HttpClient: f.HttpClient, + Browser: f.Browser, } var colorFlag string @@ -46,7 +51,9 @@ func NewCmdDiff(f *cmdutil.Factory, runF func(*DiffOptions) error) *cobra.Comman View changes in a pull request. Without an argument, the pull request that belongs to the current branch - is selected. + is selected. + + With '--web', open the pull request diff in a web browser instead. `), Args: cobra.MaximumNArgs(1), RunE: func(cmd *cobra.Command, args []string) error { @@ -81,6 +88,7 @@ func NewCmdDiff(f *cmdutil.Factory, runF func(*DiffOptions) error) *cobra.Comman cmdutil.StringEnumFlag(cmd, &colorFlag, "color", "", "auto", []string{"always", "never", "auto"}, "Use color in diff output") cmd.Flags().BoolVar(&opts.Patch, "patch", false, "Display diff in patch format") cmd.Flags().BoolVar(&opts.NameOnly, "name-only", false, "Display only names of changed files") + cmd.Flags().BoolVarP(&opts.BrowserMode, "web", "w", false, "Open the pull request diff in the browser") return cmd } @@ -90,11 +98,24 @@ func diffRun(opts *DiffOptions) error { Selector: opts.SelectorArg, Fields: []string{"number"}, } + + if opts.BrowserMode { + findOptions.Fields = []string{"url"} + } + pr, baseRepo, err := opts.Finder.Find(findOptions) if err != nil { return err } + if opts.BrowserMode { + openUrl := fmt.Sprintf("%s/files", pr.URL) + if opts.IO.IsStdoutTTY() { + fmt.Fprintf(opts.IO.ErrOut, "Opening %s in your browser.\n", text.DisplayURL(openUrl)) + } + return opts.Browser.Browse(openUrl) + } + httpClient, err := opts.HttpClient() if err != nil { return err diff --git a/pkg/cmd/pr/diff/diff_test.go b/pkg/cmd/pr/diff/diff_test.go index af0256e5b..bb8eb87bf 100644 --- a/pkg/cmd/pr/diff/diff_test.go +++ b/pkg/cmd/pr/diff/diff_test.go @@ -9,12 +9,12 @@ import ( "testing" "github.com/cli/cli/v2/api" + "github.com/cli/cli/v2/internal/browser" "github.com/cli/cli/v2/internal/ghrepo" "github.com/cli/cli/v2/pkg/cmd/pr/shared" "github.com/cli/cli/v2/pkg/cmdutil" "github.com/cli/cli/v2/pkg/httpmock" "github.com/cli/cli/v2/pkg/iostreams" - "github.com/google/go-cmp/cmp" "github.com/google/shlex" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" @@ -92,6 +92,16 @@ func Test_NewCmdDiff(t *testing.T) { isTTY: true, wantErr: "invalid argument \"doublerainbow\" for \"--color\" flag: valid values are {always|never|auto}", }, + { + name: "web mode", + args: "123 --web", + isTTY: true, + want: DiffOptions{ + SelectorArg: "123", + UseColor: true, + BrowserMode: true, + }, + }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { @@ -130,19 +140,22 @@ func Test_NewCmdDiff(t *testing.T) { assert.Equal(t, tt.want.SelectorArg, opts.SelectorArg) assert.Equal(t, tt.want.UseColor, opts.UseColor) + assert.Equal(t, tt.want.BrowserMode, opts.BrowserMode) }) } } func Test_diffRun(t *testing.T) { - pr := &api.PullRequest{Number: 123} + pr := &api.PullRequest{Number: 123, URL: "https://github.com/OWNER/REPO/pull/123"} tests := []struct { - name string - opts DiffOptions - rawDiff string - wantAccept string - wantStdout string + name string + opts DiffOptions + wantFields []string + wantStdout string + wantStderr string + wantBrowsedURL string + httpStubs func(*httpmock.Registry) }{ { name: "no color", @@ -151,9 +164,11 @@ func Test_diffRun(t *testing.T) { UseColor: false, Patch: false, }, - rawDiff: fmt.Sprintf(testDiff, "", "", "", ""), - wantAccept: "application/vnd.github.v3.diff", + wantFields: []string{"number"}, wantStdout: fmt.Sprintf(testDiff, "", "", "", ""), + httpStubs: func(reg *httpmock.Registry) { + stubDiffRequest(reg, "application/vnd.github.v3.diff", fmt.Sprintf(testDiff, "", "", "", "")) + }, }, { name: "with color", @@ -162,9 +177,11 @@ func Test_diffRun(t *testing.T) { UseColor: true, Patch: false, }, - rawDiff: fmt.Sprintf(testDiff, "", "", "", ""), - wantAccept: "application/vnd.github.v3.diff", + wantFields: []string{"number"}, wantStdout: fmt.Sprintf(testDiff, "\x1b[m", "\x1b[1;38m", "\x1b[32m", "\x1b[31m"), + httpStubs: func(reg *httpmock.Registry) { + stubDiffRequest(reg, "application/vnd.github.v3.diff", fmt.Sprintf(testDiff, "", "", "", "")) + }, }, { name: "patch format", @@ -173,9 +190,11 @@ func Test_diffRun(t *testing.T) { UseColor: false, Patch: true, }, - rawDiff: fmt.Sprintf(testDiff, "", "", "", ""), - wantAccept: "application/vnd.github.v3.patch", + wantFields: []string{"number"}, wantStdout: fmt.Sprintf(testDiff, "", "", "", ""), + httpStubs: func(reg *httpmock.Registry) { + stubDiffRequest(reg, "application/vnd.github.v3.patch", fmt.Sprintf(testDiff, "", "", "", "")) + }, }, { name: "name only", @@ -185,51 +204,51 @@ func Test_diffRun(t *testing.T) { Patch: false, NameOnly: true, }, - rawDiff: fmt.Sprintf(testDiff, "", "", "", ""), - wantAccept: "application/vnd.github.v3.diff", + wantFields: []string{"number"}, wantStdout: ".github/workflows/releases.yml\nMakefile\n", + httpStubs: func(reg *httpmock.Registry) { + stubDiffRequest(reg, "application/vnd.github.v3.diff", fmt.Sprintf(testDiff, "", "", "", "")) + }, + }, + { + name: "web mode", + opts: DiffOptions{ + SelectorArg: "123", + BrowserMode: true, + }, + wantFields: []string{"url"}, + wantStderr: "Opening github.com/OWNER/REPO/pull/123/files in your browser.\n", + wantBrowsedURL: "https://github.com/OWNER/REPO/pull/123/files", }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { httpReg := &httpmock.Registry{} defer httpReg.Verify(t) - - var gotAccept string - httpReg.Register( - httpmock.REST("GET", "repos/OWNER/REPO/pulls/123"), - func(req *http.Request) (*http.Response, error) { - gotAccept = req.Header.Get("Accept") - return &http.Response{ - StatusCode: 200, - Request: req, - Body: io.NopCloser(strings.NewReader(tt.rawDiff)), - }, nil - }) - - opts := tt.opts - opts.HttpClient = func() (*http.Client, error) { + if tt.httpStubs != nil { + tt.httpStubs(httpReg) + } + tt.opts.HttpClient = func() (*http.Client, error) { return &http.Client{Transport: httpReg}, nil } - ios, _, stdout, stderr := iostreams.Test() - opts.IO = ios - finder := shared.NewMockFinder("123", pr, ghrepo.New("OWNER", "REPO")) - finder.ExpectFields([]string{"number"}) - opts.Finder = finder + browser := &browser.Stub{} + tt.opts.Browser = browser - if err := diffRun(&opts); err != nil { - t.Fatalf("unexpected error: %s", err) - } - if diff := cmp.Diff(tt.wantStdout, stdout.String()); diff != "" { - t.Errorf("command output did not match:\n%s", diff) - } - if stderr.String() != "" { - t.Errorf("unexpected stderr output: %s", stderr.String()) - } - if gotAccept != tt.wantAccept { - t.Errorf("unexpected Accept header: %s", gotAccept) - } + ios, _, stdout, stderr := iostreams.Test() + ios.SetStdoutTTY(true) + tt.opts.IO = ios + + finder := shared.NewMockFinder("123", pr, ghrepo.New("OWNER", "REPO")) + finder.ExpectFields(tt.wantFields) + tt.opts.Finder = finder + + err := diffRun(&tt.opts) + assert.NoError(t, err) + + assert.Equal(t, tt.wantStdout, stdout.String()) + assert.Equal(t, tt.wantStderr, stderr.String()) + assert.Equal(t, tt.wantBrowsedURL, browser.BrowsedURL()) }) } } @@ -349,3 +368,23 @@ func Test_changedFileNames(t *testing.T) { } } } + +func stubDiffRequest(reg *httpmock.Registry, accept, diff string) { + reg.Register( + func(req *http.Request) bool { + if !strings.EqualFold(req.Method, "GET") { + return false + } + if req.URL.EscapedPath() != "/repos/OWNER/REPO/pulls/123" { + return false + } + return req.Header.Get("Accept") == accept + }, + func(req *http.Request) (*http.Response, error) { + return &http.Response{ + StatusCode: 200, + Request: req, + Body: io.NopCloser(strings.NewReader(diff)), + }, nil + }) +} From 0197b51f7212cd80242fcc0fac6dbd8032630f86 Mon Sep 17 00:00:00 2001 From: Robin Neatherway Date: Tue, 18 Oct 2022 12:08:47 +0100 Subject: [PATCH 40/44] Replace spaces with tabs in mixed whitespace string (#6456) This makes the indentation of the query consistent no matter your local tab size rendering. --- api/query_builder.go | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/api/query_builder.go b/api/query_builder.go index 5ed9bcb74..2fb45bd81 100644 --- a/api/query_builder.go +++ b/api/query_builder.go @@ -185,7 +185,7 @@ func RequiredStatusCheckRollupGraphQL(prID, after string) string { state, targetUrl, createdAt, - isRequired(pullRequestId: %[2]s) + isRequired(pullRequestId: %[2]s) }, ...on CheckRun { name, @@ -195,7 +195,7 @@ func RequiredStatusCheckRollupGraphQL(prID, after string) string { startedAt, completedAt, detailsUrl, - isRequired(pullRequestId: %[2]s) + isRequired(pullRequestId: %[2]s) } }, pageInfo{hasNextPage,endCursor} From 57fbe4f317ca7d0849eeeedb16c1abc21a81913b Mon Sep 17 00:00:00 2001 From: Sam Coe Date: Wed, 19 Oct 2022 21:11:36 +0300 Subject: [PATCH 41/44] Refactor to use new git client (#6447) --- context/context.go | 4 +- git/client.go | 185 +++++++++++++++------ git/client_test.go | 2 +- git/git.go | 154 ----------------- internal/run/run.go | 4 + pkg/cmd/auth/login/login.go | 4 + pkg/cmd/auth/login/login_test.go | 3 + pkg/cmd/auth/refresh/refresh.go | 10 +- pkg/cmd/auth/refresh/refresh_test.go | 2 +- pkg/cmd/auth/setupgit/setupgit.go | 1 + pkg/cmd/auth/shared/git_credential.go | 30 ++-- pkg/cmd/auth/shared/git_credential_test.go | 4 + pkg/cmd/auth/shared/login_flow.go | 8 +- pkg/cmd/browse/browse.go | 21 ++- pkg/cmd/browse/browse_test.go | 2 +- pkg/cmd/extension/manager.go | 4 +- pkg/cmd/factory/default.go | 23 +-- pkg/cmd/gist/clone/clone.go | 5 +- pkg/cmd/gist/clone/clone_test.go | 2 + pkg/cmd/pr/checkout/checkout.go | 65 ++++---- pkg/cmd/pr/checkout/checkout_test.go | 3 + pkg/cmd/pr/close/close.go | 10 +- pkg/cmd/pr/close/close_test.go | 2 + pkg/cmd/pr/create/create.go | 49 +++--- pkg/cmd/pr/create/create_test.go | 4 +- pkg/cmd/pr/merge/merge.go | 25 +-- pkg/cmd/pr/merge/merge_test.go | 1 + pkg/cmd/pr/shared/finder.go | 14 +- pkg/cmd/pr/shared/survey.go | 17 -- pkg/cmd/pr/shared/templates.go | 4 +- pkg/cmd/pr/status/status.go | 13 +- pkg/cmd/pr/status/status_test.go | 4 +- pkg/cmd/release/create/create.go | 23 +-- pkg/cmd/release/create/create_test.go | 5 + pkg/cmd/repo/clone/clone.go | 9 +- pkg/cmd/repo/clone/clone_test.go | 2 + pkg/cmd/repo/create/create.go | 76 +++++---- pkg/cmd/repo/create/create_test.go | 3 + pkg/cmd/repo/fork/fork.go | 21 ++- pkg/cmd/repo/fork/fork_test.go | 2 + pkg/cmd/repo/rename/rename.go | 12 +- pkg/cmd/repo/rename/rename_test.go | 2 + pkg/cmd/repo/sync/git.go | 46 ++--- pkg/cmd/repo/sync/sync.go | 2 +- 44 files changed, 459 insertions(+), 423 deletions(-) delete mode 100644 git/git.go diff --git a/context/context.go b/context/context.go index 45dfcfd97..568e3e623 100644 --- a/context/context.go +++ b/context/context.go @@ -2,6 +2,7 @@ package context import ( + "context" "errors" "sort" @@ -138,7 +139,8 @@ func (r *ResolvedRemotes) BaseRepo(io *iostreams.IOStreams, p iprompter) (ghrepo } // cache the result to git config - err := git.SetRemoteResolution(remote.Name, resolution) + c := &git.Client{} + err := c.SetRemoteResolution(context.Background(), remote.Name, resolution) return selectedRepo, err } diff --git a/git/client.go b/git/client.go index c6bfc21ac..b91d0aacb 100644 --- a/git/client.go +++ b/git/client.go @@ -38,22 +38,16 @@ func (e *NotInstalled) Unwrap() error { } type GitError struct { - stderr string - err error + ExitCode int + Stderr string + err error } func (ge *GitError) Error() string { - stderr := ge.stderr - if stderr == "" { - var exitError *exec.ExitError - if errors.As(ge.err, &exitError) { - stderr = string(exitError.Stderr) - } - } - if stderr == "" { + if ge.Stderr == "" { return fmt.Sprintf("failed to run git: %v", ge.err) } - return fmt.Sprintf("failed to run git: %s", stderr) + return fmt.Sprintf("failed to run git: %s", ge.Stderr) } func (ge *GitError) Unwrap() error { @@ -64,16 +58,77 @@ type gitCommand struct { *exec.Cmd } -// This is a hack in order to not break the hundreds of -// existing tests that rely on `run.PrepareCmd` to be invoked. func (gc *gitCommand) Run() error { - return run.PrepareCmd(gc.Cmd).Run() + // This is a hack in order to not break the hundreds of + // existing tests that rely on `run.PrepareCmd` to be invoked. + err := run.PrepareCmd(gc.Cmd).Run() + if err != nil { + ge := GitError{err: err} + var exitError *exec.ExitError + if errors.As(err, &exitError) { + ge.Stderr = string(exitError.Stderr) + ge.ExitCode = exitError.ExitCode() + } + return &ge + } + return nil } -// This is a hack in order to not break the hundreds of -// existing tests that rely on `run.PrepareCmd` to be invoked. func (gc *gitCommand) Output() ([]byte, error) { - return run.PrepareCmd(gc.Cmd).Output() + gc.Stdout = nil + gc.Stderr = nil + // This is a hack in order to not break the hundreds of + // existing tests that rely on `run.PrepareCmd` to be invoked. + out, err := run.PrepareCmd(gc.Cmd).Output() + if err != nil { + ge := GitError{err: err} + var exitError *exec.ExitError + if errors.As(err, &exitError) { + ge.Stderr = string(exitError.Stderr) + ge.ExitCode = exitError.ExitCode() + } + return []byte{}, &ge + } + return out, nil +} + +func (gc *gitCommand) setRepoDir(repoDir string) { + for i, arg := range gc.Args { + if arg == "-C" { + gc.Args[i+1] = repoDir + return + } + } + gc.Args = append(gc.Args[:3], gc.Args[1:]...) + gc.Args[1] = "-C" + gc.Args[2] = repoDir +} + +// Allow individual commands to be modified from the default client options. +type CommandModifier func(*gitCommand) + +func WithStderr(stderr io.Writer) CommandModifier { + return func(gc *gitCommand) { + gc.Stderr = stderr + } +} + +func WithStdout(stdout io.Writer) CommandModifier { + return func(gc *gitCommand) { + gc.Stdout = stdout + } +} + +func WithStdin(stdin io.Reader) CommandModifier { + return func(gc *gitCommand) { + gc.Stdin = stdin + } +} + +func WithRepoDir(repoDir string) CommandModifier { + return func(gc *gitCommand) { + gc.setRepoDir(repoDir) + } } type Client struct { @@ -133,8 +188,7 @@ func resolveGitPath() (string, error) { // AuthenticatedCommand is a wrapper around Command that included configuration to use gh // as the credential helper for git. func (c *Client) AuthenticatedCommand(ctx context.Context, args ...string) (*gitCommand, error) { - preArgs := []string{} - preArgs = append(preArgs, "-c", "credential.helper=") + preArgs := []string{"-c", "credential.helper="} if c.GhPath == "" { // Assumes that gh is in PATH. c.GhPath = "gh" @@ -153,7 +207,7 @@ func (c *Client) Remotes(ctx context.Context) (RemoteSet, error) { } remoteOut, remoteErr := remoteCmd.Output() if remoteErr != nil { - return nil, &GitError{err: remoteErr} + return nil, remoteErr } configArgs := []string{"config", "--get-regexp", `^remote\..*\.gh-resolved$`} @@ -164,9 +218,9 @@ func (c *Client) Remotes(ctx context.Context) (RemoteSet, error) { configOut, configErr := configCmd.Output() if configErr != nil { // Ignore exit code 1 as it means there are no resolved remotes. - var exitErr *exec.ExitError - if errors.As(configErr, &exitErr) && exitErr.ExitCode() != 1 { - return nil, &GitError{err: configErr} + var gitErr *GitError + if ok := errors.As(configErr, &gitErr); ok && gitErr.ExitCode != 1 { + return nil, gitErr } } @@ -176,18 +230,20 @@ func (c *Client) Remotes(ctx context.Context) (RemoteSet, error) { return remotes, nil } -func (c *Client) AddRemote(ctx context.Context, name, urlStr string, trackingBranches []string) (*Remote, error) { +func (c *Client) AddRemote(ctx context.Context, name, urlStr string, trackingBranches []string, mods ...CommandModifier) (*Remote, error) { args := []string{"remote", "add"} for _, branch := range trackingBranches { args = append(args, "-t", branch) } args = append(args, "-f", name, urlStr) - //TODO: Use AuthenticatedCommand cmd, err := c.Command(ctx, args...) if err != nil { return nil, err } - if err := cmd.Run(); err != nil { + for _, mod := range mods { + mod(cmd) + } + if _, err := cmd.Output(); err != nil { return nil, err } var urlParsed *url.URL @@ -216,7 +272,11 @@ func (c *Client) UpdateRemoteURL(ctx context.Context, name, url string) error { if err != nil { return err } - return cmd.Run() + _, err = cmd.Output() + if err != nil { + return err + } + return nil } func (c *Client) SetRemoteResolution(ctx context.Context, name, resolution string) error { @@ -225,7 +285,11 @@ func (c *Client) SetRemoteResolution(ctx context.Context, name, resolution strin if err != nil { return err } - return cmd.Run() + _, err = cmd.Output() + if err != nil { + return err + } + return nil } // CurrentBranch reads the checked-out branch for the git repository. @@ -235,14 +299,14 @@ func (c *Client) CurrentBranch(ctx context.Context) (string, error) { if err != nil { return "", err } - errBuf := bytes.Buffer{} - cmd.Stderr = &errBuf out, err := cmd.Output() if err != nil { - if errBuf.Len() == 0 { - return "", &GitError{err: err, stderr: "not on any branch"} + var gitErr *GitError + if ok := errors.As(err, &gitErr); ok && len(gitErr.Stderr) == 0 { + gitErr.Stderr = "not on any branch" + return "", gitErr } - return "", &GitError{err: err, stderr: errBuf.String()} + return "", err } branch := firstLine(out) return strings.TrimPrefix(branch, "refs/heads/"), nil @@ -257,7 +321,7 @@ func (c *Client) ShowRefs(ctx context.Context, ref ...string) ([]Ref, error) { } out, err := cmd.Output() if err != nil { - return nil, &GitError{err: err} + return nil, err } var refs []Ref for _, line := range outputLines(out) { @@ -279,15 +343,14 @@ func (c *Client) Config(ctx context.Context, name string) (string, error) { if err != nil { return "", err } - errBuf := bytes.Buffer{} - cmd.Stderr = &errBuf out, err := cmd.Output() if err != nil { - var exitError *exec.ExitError - if ok := errors.As(err, &exitError); ok && exitError.Error() == "1" { - return "", &GitError{err: err, stderr: fmt.Sprintf("unknown config key %s", name)} + var gitErr *GitError + if ok := errors.As(err, &gitErr); ok && gitErr.ExitCode == 1 { + gitErr.Stderr = fmt.Sprintf("unknown config key %s", name) + return "", gitErr } - return "", &GitError{err: err, stderr: errBuf.String()} + return "", err } return firstLine(out), nil } @@ -300,7 +363,7 @@ func (c *Client) UncommittedChangeCount(ctx context.Context) (int, error) { } out, err := cmd.Output() if err != nil { - return 0, &GitError{err: err} + return 0, err } lines := strings.Split(string(out), "\n") count := 0 @@ -320,7 +383,7 @@ func (c *Client) Commits(ctx context.Context, baseRef, headRef string) ([]*Commi } out, err := cmd.Output() if err != nil { - return nil, &GitError{err: err} + return nil, err } commits := []*Commit{} sha := 0 @@ -349,7 +412,7 @@ func (c *Client) lookupCommit(ctx context.Context, sha, format string) ([]byte, } out, err := cmd.Output() if err != nil { - return nil, &GitError{err: err} + return nil, err } return out, nil } @@ -372,13 +435,16 @@ func (c *Client) CommitBody(ctx context.Context, sha string) (string, error) { } // Push publishes a git ref to a remote and sets up upstream configuration. -func (c *Client) Push(ctx context.Context, remote string, ref string) error { +func (c *Client) Push(ctx context.Context, remote string, ref string, mods ...CommandModifier) error { args := []string{"push", "--set-upstream", remote, ref} //TODO: Use AuthenticatedCommand cmd, err := c.Command(ctx, args...) if err != nil { return err } + for _, mod := range mods { + mod(cmd) + } return cmd.Run() } @@ -424,7 +490,11 @@ func (c *Client) DeleteLocalBranch(ctx context.Context, branch string) error { if err != nil { return err } - return cmd.Run() + _, err = cmd.Output() + if err != nil { + return err + } + return nil } func (c *Client) HasLocalBranch(ctx context.Context, branch string) bool { @@ -433,7 +503,7 @@ func (c *Client) HasLocalBranch(ctx context.Context, branch string) bool { if err != nil { return false } - err = cmd.Run() + _, err = cmd.Output() return err == nil } @@ -443,7 +513,11 @@ func (c *Client) CheckoutBranch(ctx context.Context, branch string) error { if err != nil { return err } - return cmd.Run() + _, err = cmd.Output() + if err != nil { + return err + } + return nil } func (c *Client) CheckoutNewBranch(ctx context.Context, remoteName, branch string) error { @@ -453,7 +527,11 @@ func (c *Client) CheckoutNewBranch(ctx context.Context, remoteName, branch strin if err != nil { return err } - return cmd.Run() + _, err = cmd.Output() + if err != nil { + return err + } + return nil } func (c *Client) Pull(ctx context.Context, remote, branch string) error { @@ -466,7 +544,7 @@ func (c *Client) Pull(ctx context.Context, remote, branch string) error { return cmd.Run() } -func (c *Client) Clone(ctx context.Context, cloneURL string, args []string) (target string, err error) { +func (c *Client) Clone(ctx context.Context, cloneURL string, args []string) (string, error) { cloneArgs, target := parseCloneArgs(args) cloneArgs = append(cloneArgs, cloneURL) // If the args contain an explicit target, pass it to clone @@ -483,7 +561,10 @@ func (c *Client) Clone(ctx context.Context, cloneURL string, args []string) (tar return "", err } err = cmd.Run() - return + if err != nil { + return "", err + } + return target, nil } // ToplevelDir returns the top-level directory path of the current repository. @@ -495,7 +576,7 @@ func (c *Client) ToplevelDir(ctx context.Context) (string, error) { } out, err := cmd.Output() if err != nil { - return "", &GitError{err: err} + return "", err } return firstLine(out), nil } @@ -508,7 +589,7 @@ func (c *Client) GitDir(ctx context.Context) (string, error) { } out, err := cmd.Output() if err != nil { - return "", &GitError{err: err} + return "", err } return firstLine(out), nil } diff --git a/git/client_test.go b/git/client_test.go index 121231d23..cff47358f 100644 --- a/git/client_test.go +++ b/git/client_test.go @@ -394,6 +394,6 @@ func initRepo(t *testing.T, dir string) { } cmd, err := client.Command(context.Background(), []string{"init", "--quiet"}...) assert.NoError(t, err) - err = cmd.Run() + _, err = cmd.Output() assert.NoError(t, err) } diff --git a/git/git.go b/git/git.go deleted file mode 100644 index 5b934175b..000000000 --- a/git/git.go +++ /dev/null @@ -1,154 +0,0 @@ -package git - -import ( - "context" - "io" - "os" -) - -func GitCommand(args ...string) (*gitCommand, error) { - c := &Client{} - return c.Command(context.Background(), args...) -} - -func ShowRefs(ref ...string) ([]Ref, error) { - c := &Client{} - return c.ShowRefs(context.Background(), ref...) -} - -func CurrentBranch() (string, error) { - c := &Client{} - return c.CurrentBranch(context.Background()) -} - -func Config(name string) (string, error) { - c := &Client{} - return c.Config(context.Background(), name) -} - -func UncommittedChangeCount() (int, error) { - c := &Client{} - return c.UncommittedChangeCount(context.Background()) -} - -func Commits(baseRef, headRef string) ([]*Commit, error) { - c := &Client{} - return c.Commits(context.Background(), baseRef, headRef) -} - -func LastCommit() (*Commit, error) { - c := &Client{} - return c.LastCommit(context.Background()) -} - -func CommitBody(sha string) (string, error) { - c := &Client{} - return c.CommitBody(context.Background(), sha) -} - -func Push(remote string, ref string, cmdIn io.ReadCloser, cmdOut, cmdErr io.Writer) error { - //TODO: Replace with factory GitClient and use AuthenticatedCommand - c := &Client{ - Stdin: cmdIn, - Stdout: cmdOut, - Stderr: cmdErr, - } - return c.Push(context.Background(), remote, ref) -} - -func ReadBranchConfig(branch string) (cfg BranchConfig) { - c := &Client{} - return c.ReadBranchConfig(context.Background(), branch) -} - -func DeleteLocalBranch(branch string) error { - c := &Client{} - return c.DeleteLocalBranch(context.Background(), branch) -} - -func HasLocalBranch(branch string) bool { - c := &Client{} - return c.HasLocalBranch(context.Background(), branch) -} - -func CheckoutBranch(branch string) error { - c := &Client{} - return c.CheckoutBranch(context.Background(), branch) -} - -func CheckoutNewBranch(remoteName, branch string) error { - c := &Client{} - return c.CheckoutNewBranch(context.Background(), remoteName, branch) -} - -func Pull(remote, branch string) error { - //TODO: Replace with factory GitClient and use AuthenticatedCommand - c := &Client{ - Stdin: os.Stdin, - Stdout: os.Stdout, - Stderr: os.Stderr, - } - return c.Pull(context.Background(), remote, branch) -} - -func RunClone(cloneURL string, args []string) (target string, err error) { - //TODO: Replace with factory GitClient and use AuthenticatedCommand - c := &Client{ - Stdin: os.Stdin, - Stdout: os.Stdout, - Stderr: os.Stderr, - } - return c.Clone(context.Background(), cloneURL, args) -} - -func ToplevelDir() (string, error) { - c := &Client{} - return c.ToplevelDir(context.Background()) -} - -func GetDirFromPath(repoDir string) (string, error) { - c := &Client{ - RepoDir: repoDir, - } - return c.GitDir(context.Background()) -} - -func PathFromRepoRoot() string { - c := &Client{} - return c.PathFromRoot(context.Background()) -} - -func Remotes() (RemoteSet, error) { - c := &Client{} - return c.Remotes(context.Background()) -} - -func RemotesForPath(repoDir string) (RemoteSet, error) { - c := &Client{ - RepoDir: repoDir, - } - return c.Remotes(context.Background()) -} - -func AddRemote(name, url string) (*Remote, error) { - c := &Client{} - return c.AddRemote(context.Background(), name, url, []string{}) -} - -func AddNamedRemote(url, name, repoDir string, branches []string) error { - c := &Client{ - RepoDir: repoDir, - } - _, err := c.AddRemote(context.Background(), name, url, branches) - return err -} - -func UpdateRemoteURL(name, url string) error { - c := &Client{} - return c.UpdateRemoteURL(context.Background(), name, url) -} - -func SetRemoteResolution(name, resolution string) error { - c := &Client{} - return c.SetRemoteResolution(context.Background(), name, resolution) -} diff --git a/internal/run/run.go b/internal/run/run.go index d482e04cc..a7260db14 100644 --- a/internal/run/run.go +++ b/internal/run/run.go @@ -76,6 +76,10 @@ func (e CmdError) Error() string { return fmt.Sprintf("%s%s: %s", msg, e.Args[0], e.Err) } +func (e CmdError) Unwrap() error { + return e.Err +} + func printArgs(w io.Writer, args []string) error { if len(args) > 0 { // print commands, but omit the full path to an executable diff --git a/pkg/cmd/auth/login/login.go b/pkg/cmd/auth/login/login.go index be12392d1..364abb9f0 100644 --- a/pkg/cmd/auth/login/login.go +++ b/pkg/cmd/auth/login/login.go @@ -7,6 +7,7 @@ import ( "strings" "github.com/MakeNowJust/heredoc" + "github.com/cli/cli/v2/git" "github.com/cli/cli/v2/internal/config" "github.com/cli/cli/v2/internal/ghinstance" "github.com/cli/cli/v2/pkg/cmd/auth/shared" @@ -20,6 +21,7 @@ type LoginOptions struct { IO *iostreams.IOStreams Config func() (config.Config, error) HttpClient func() (*http.Client, error) + GitClient *git.Client Prompter shared.Prompt MainExecutable string @@ -38,6 +40,7 @@ func NewCmdLogin(f *cmdutil.Factory, runF func(*LoginOptions) error) *cobra.Comm IO: f.IOStreams, Config: f.Config, HttpClient: f.HttpClient, + GitClient: f.GitClient, Prompter: f.Prompter, } @@ -183,6 +186,7 @@ func loginRun(opts *LoginOptions) error { Executable: opts.MainExecutable, GitProtocol: opts.GitProtocol, Prompter: opts.Prompter, + GitClient: opts.GitClient, }) } diff --git a/pkg/cmd/auth/login/login_test.go b/pkg/cmd/auth/login/login_test.go index 649fdb48d..85e133b2b 100644 --- a/pkg/cmd/auth/login/login_test.go +++ b/pkg/cmd/auth/login/login_test.go @@ -8,6 +8,7 @@ import ( "testing" "github.com/MakeNowJust/heredoc" + "github.com/cli/cli/v2/git" "github.com/cli/cli/v2/internal/config" "github.com/cli/cli/v2/internal/prompter" "github.com/cli/cli/v2/internal/run" @@ -597,6 +598,8 @@ func Test_loginRun_Survey(t *testing.T) { } tt.opts.Prompter = pm + tt.opts.GitClient = &git.Client{GitPath: "some/path/git"} + rs, restoreRun := run.Stub() defer restoreRun(t) if tt.runStubs != nil { diff --git a/pkg/cmd/auth/refresh/refresh.go b/pkg/cmd/auth/refresh/refresh.go index 7b1142e7e..c66fb5237 100644 --- a/pkg/cmd/auth/refresh/refresh.go +++ b/pkg/cmd/auth/refresh/refresh.go @@ -6,6 +6,7 @@ import ( "strings" "github.com/MakeNowJust/heredoc" + "github.com/cli/cli/v2/git" "github.com/cli/cli/v2/internal/authflow" "github.com/cli/cli/v2/internal/config" "github.com/cli/cli/v2/pkg/cmd/auth/shared" @@ -17,7 +18,8 @@ import ( type RefreshOptions struct { IO *iostreams.IOStreams Config func() (config.Config, error) - httpClient *http.Client + HttpClient *http.Client + GitClient *git.Client Prompter shared.Prompt MainExecutable string @@ -37,7 +39,8 @@ func NewCmdRefresh(f *cmdutil.Factory, runF func(*RefreshOptions) error) *cobra. _, err := authflow.AuthFlowWithConfig(cfg, io, hostname, "", scopes, interactive) return err }, - httpClient: &http.Client{}, + HttpClient: &http.Client{}, + GitClient: f.GitClient, Prompter: f.Prompter, } @@ -122,7 +125,7 @@ func refreshRun(opts *RefreshOptions) error { var additionalScopes []string if oldToken, _ := cfg.AuthToken(hostname); oldToken != "" { - if oldScopes, err := shared.GetScopes(opts.httpClient, hostname, oldToken); err == nil { + if oldScopes, err := shared.GetScopes(opts.HttpClient, hostname, oldToken); err == nil { for _, s := range strings.Split(oldScopes, ",") { s = strings.TrimSpace(s) if s != "" { @@ -135,6 +138,7 @@ func refreshRun(opts *RefreshOptions) error { credentialFlow := &shared.GitCredentialFlow{ Executable: opts.MainExecutable, Prompter: opts.Prompter, + GitClient: opts.GitClient, } gitProtocol, _ := cfg.GetOrDefault(hostname, "git_protocol") if opts.Interactive && gitProtocol == "https" { diff --git a/pkg/cmd/auth/refresh/refresh_test.go b/pkg/cmd/auth/refresh/refresh_test.go index ffacb22d9..d1d6042c0 100644 --- a/pkg/cmd/auth/refresh/refresh_test.go +++ b/pkg/cmd/auth/refresh/refresh_test.go @@ -272,7 +272,7 @@ func Test_refreshRun(t *testing.T) { }, nil }, ) - tt.opts.httpClient = &http.Client{Transport: httpReg} + tt.opts.HttpClient = &http.Client{Transport: httpReg} pm := &prompter.PrompterMock{} if tt.prompterStubs != nil { diff --git a/pkg/cmd/auth/setupgit/setupgit.go b/pkg/cmd/auth/setupgit/setupgit.go index b5caefe5b..edc531235 100644 --- a/pkg/cmd/auth/setupgit/setupgit.go +++ b/pkg/cmd/auth/setupgit/setupgit.go @@ -34,6 +34,7 @@ func NewCmdSetupGit(f *cmdutil.Factory, runF func(*SetupGitOptions) error) *cobr RunE: func(cmd *cobra.Command, args []string) error { opts.gitConfigure = &shared.GitCredentialFlow{ Executable: f.Executable(), + GitClient: f.GitClient, } if runF != nil { diff --git a/pkg/cmd/auth/shared/git_credential.go b/pkg/cmd/auth/shared/git_credential.go index 491e2c82f..5ddac14d5 100644 --- a/pkg/cmd/auth/shared/git_credential.go +++ b/pkg/cmd/auth/shared/git_credential.go @@ -2,6 +2,7 @@ package shared import ( "bytes" + "context" "errors" "fmt" "path/filepath" @@ -16,6 +17,7 @@ import ( type GitCredentialFlow struct { Executable string Prompter Prompt + GitClient *git.Client shouldSetup bool helper string @@ -24,7 +26,7 @@ type GitCredentialFlow struct { func (flow *GitCredentialFlow) Prompt(hostname string) error { var gitErr error - flow.helper, gitErr = gitCredentialHelper(hostname) + flow.helper, gitErr = gitCredentialHelper(flow.GitClient, hostname) if isOurCredentialHelper(flow.helper) { flow.scopes = append(flow.scopes, "workflow") return nil @@ -59,6 +61,9 @@ func (flow *GitCredentialFlow) Setup(hostname, username, authToken string) error } func (flow *GitCredentialFlow) gitCredentialSetup(hostname, username, password string) error { + gitClient := flow.GitClient + ctx := context.Background() + if flow.helper == "" { credHelperKeys := []string{ gitCredentialHelperKey(hostname), @@ -76,18 +81,18 @@ func (flow *GitCredentialFlow) gitCredentialSetup(hostname, username, password s break } // first use a blank value to indicate to git we want to sever the chain of credential helpers - preConfigureCmd, err := git.GitCommand("config", "--global", "--replace-all", credHelperKey, "") + preConfigureCmd, err := gitClient.Command(ctx, "config", "--global", "--replace-all", credHelperKey, "") if err != nil { configErr = err break } - if err = preConfigureCmd.Run(); err != nil { + if _, err = preConfigureCmd.Output(); err != nil { configErr = err break } // second configure the actual helper for this host - configureCmd, err := git.GitCommand( + configureCmd, err := gitClient.Command(ctx, "config", "--global", "--add", credHelperKey, fmt.Sprintf("!%s auth git-credential", shellQuote(flow.Executable)), @@ -95,7 +100,7 @@ func (flow *GitCredentialFlow) gitCredentialSetup(hostname, username, password s if err != nil { configErr = err } else { - configErr = configureCmd.Run() + _, configErr = configureCmd.Output() } } @@ -103,7 +108,7 @@ func (flow *GitCredentialFlow) gitCredentialSetup(hostname, username, password s } // clear previous cached credentials - rejectCmd, err := git.GitCommand("credential", "reject") + rejectCmd, err := gitClient.Command(ctx, "credential", "reject") if err != nil { return err } @@ -113,12 +118,12 @@ func (flow *GitCredentialFlow) gitCredentialSetup(hostname, username, password s host=%s `, hostname)) - err = rejectCmd.Run() + _, err = rejectCmd.Output() if err != nil { return err } - approveCmd, err := git.GitCommand("credential", "approve") + approveCmd, err := gitClient.Command(ctx, "credential", "approve") if err != nil { return err } @@ -130,7 +135,7 @@ func (flow *GitCredentialFlow) gitCredentialSetup(hostname, username, password s password=%s `, hostname, username, password)) - err = approveCmd.Run() + _, err = approveCmd.Output() if err != nil { return err } @@ -143,12 +148,13 @@ func gitCredentialHelperKey(hostname string) string { return fmt.Sprintf("credential.%s.helper", host) } -func gitCredentialHelper(hostname string) (helper string, err error) { - helper, err = git.Config(gitCredentialHelperKey(hostname)) +func gitCredentialHelper(gitClient *git.Client, hostname string) (helper string, err error) { + ctx := context.Background() + helper, err = gitClient.Config(ctx, gitCredentialHelperKey(hostname)) if helper != "" { return } - helper, err = git.Config("credential.helper") + helper, err = gitClient.Config(ctx, "credential.helper") return } diff --git a/pkg/cmd/auth/shared/git_credential_test.go b/pkg/cmd/auth/shared/git_credential_test.go index fe674e1d7..9d3e90cc4 100644 --- a/pkg/cmd/auth/shared/git_credential_test.go +++ b/pkg/cmd/auth/shared/git_credential_test.go @@ -3,6 +3,7 @@ package shared import ( "testing" + "github.com/cli/cli/v2/git" "github.com/cli/cli/v2/internal/run" ) @@ -15,6 +16,7 @@ func TestGitCredentialSetup_configureExisting(t *testing.T) { f := GitCredentialFlow{ Executable: "gh", helper: "osxkeychain", + GitClient: &git.Client{GitPath: "some/path/git"}, } if err := f.gitCredentialSetup("example.com", "monalisa", "PASSWD"); err != nil { @@ -61,6 +63,7 @@ func TestGitCredentialsSetup_setOurs_GH(t *testing.T) { f := GitCredentialFlow{ Executable: "/path/to/gh", helper: "", + GitClient: &git.Client{GitPath: "some/path/git"}, } if err := f.gitCredentialSetup("github.com", "monalisa", "PASSWD"); err != nil { @@ -92,6 +95,7 @@ func TestGitCredentialSetup_setOurs_nonGH(t *testing.T) { f := GitCredentialFlow{ Executable: "/path/to/gh", helper: "", + GitClient: &git.Client{GitPath: "some/path/git"}, } if err := f.gitCredentialSetup("example.com", "monalisa", "PASSWD"); err != nil { diff --git a/pkg/cmd/auth/shared/login_flow.go b/pkg/cmd/auth/shared/login_flow.go index 9cc9d9865..99d82b5f7 100644 --- a/pkg/cmd/auth/shared/login_flow.go +++ b/pkg/cmd/auth/shared/login_flow.go @@ -8,6 +8,7 @@ import ( "github.com/MakeNowJust/heredoc" "github.com/cli/cli/v2/api" + "github.com/cli/cli/v2/git" "github.com/cli/cli/v2/internal/authflow" "github.com/cli/cli/v2/internal/ghinstance" "github.com/cli/cli/v2/pkg/cmd/ssh-key/add" @@ -27,6 +28,7 @@ type LoginOptions struct { IO *iostreams.IOStreams Config iconfig HTTPClient *http.Client + GitClient *git.Client Hostname string Interactive bool Web bool @@ -63,7 +65,11 @@ func Login(opts *LoginOptions) error { var additionalScopes []string - credentialFlow := &GitCredentialFlow{Executable: opts.Executable, Prompter: opts.Prompter} + credentialFlow := &GitCredentialFlow{ + Executable: opts.Executable, + Prompter: opts.Prompter, + GitClient: opts.GitClient, + } if opts.Interactive && gitProtocol == "https" { if err := credentialFlow.Prompt(hostname); err != nil { return err diff --git a/pkg/cmd/browse/browse.go b/pkg/cmd/browse/browse.go index ddff8bd92..85572fdac 100644 --- a/pkg/cmd/browse/browse.go +++ b/pkg/cmd/browse/browse.go @@ -1,6 +1,7 @@ package browse import ( + "context" "fmt" "net/http" "net/url" @@ -41,11 +42,13 @@ type BrowseOptions struct { func NewCmdBrowse(f *cmdutil.Factory, runF func(*BrowseOptions) error) *cobra.Command { opts := &BrowseOptions{ - Browser: f.Browser, - HttpClient: f.HttpClient, - IO: f.IOStreams, - PathFromRepoRoot: git.PathFromRepoRoot, - GitClient: &localGitClient{}, + Browser: f.Browser, + HttpClient: f.HttpClient, + IO: f.IOStreams, + PathFromRepoRoot: func() string { + return f.GitClient.PathFromRoot(context.Background()) + }, + GitClient: &localGitClient{client: f.GitClient}, } cmd := &cobra.Command{ @@ -269,14 +272,18 @@ type gitClient interface { LastCommit() (*git.Commit, error) } -type localGitClient struct{} +type localGitClient struct { + client *git.Client +} type remoteGitClient struct { repo func() (ghrepo.Interface, error) httpClient func() (*http.Client, error) } -func (gc *localGitClient) LastCommit() (*git.Commit, error) { return git.LastCommit() } +func (gc *localGitClient) LastCommit() (*git.Commit, error) { + return gc.client.LastCommit(context.Background()) +} func (gc *remoteGitClient) LastCommit() (*git.Commit, error) { httpClient, err := gc.httpClient() diff --git a/pkg/cmd/browse/browse_test.go b/pkg/cmd/browse/browse_test.go index 91197cc96..75e615be0 100644 --- a/pkg/cmd/browse/browse_test.go +++ b/pkg/cmd/browse/browse_test.go @@ -466,7 +466,7 @@ func Test_runBrowse(t *testing.T) { } opts.Browser = &browser if opts.PathFromRepoRoot == nil { - opts.PathFromRepoRoot = git.PathFromRepoRoot + opts.PathFromRepoRoot = func() string { return "" } } err := runBrowse(&opts) diff --git a/pkg/cmd/extension/manager.go b/pkg/cmd/extension/manager.go index 289a9aefa..c9f638b87 100644 --- a/pkg/cmd/extension/manager.go +++ b/pkg/cmd/extension/manager.go @@ -2,6 +2,7 @@ package extension import ( "bytes" + "context" _ "embed" "errors" "fmt" @@ -789,7 +790,8 @@ func isBinExtension(client *http.Client, repo ghrepo.Interface) (isBin bool, err } func repoFromPath(path string) (ghrepo.Interface, error) { - remotes, err := git.RemotesForPath(path) + gitClient := &git.Client{RepoDir: path} + remotes, err := gitClient.Remotes(context.Background()) if err != nil { return nil, err } diff --git a/pkg/cmd/factory/default.go b/pkg/cmd/factory/default.go index 77552c977..10bf5d72b 100644 --- a/pkg/cmd/factory/default.go +++ b/pkg/cmd/factory/default.go @@ -1,6 +1,7 @@ package factory import ( + "context" "fmt" "net/http" "os" @@ -8,7 +9,7 @@ import ( "time" "github.com/cli/cli/v2/api" - "github.com/cli/cli/v2/context" + ghContext "github.com/cli/cli/v2/context" "github.com/cli/cli/v2/git" "github.com/cli/cli/v2/internal/browser" "github.com/cli/cli/v2/internal/config" @@ -25,18 +26,18 @@ var ssoURLRE = regexp.MustCompile(`\burl=([^;]+)`) func New(appVersion string) *cmdutil.Factory { f := &cmdutil.Factory{ Config: configFunc(), // No factory dependencies - Branch: branchFunc(), // No factory dependencies ExecutableName: "gh", } f.IOStreams = ioStreams(f) // Depends on Config f.HttpClient = httpClientFunc(f, appVersion) // Depends on Config, IOStreams, and appVersion - f.Remotes = remotesFunc(f) // Depends on Config + f.GitClient = newGitClient(f) // Depends on IOStreams, and Executable + f.Remotes = remotesFunc(f) // Depends on Config, and GitClient f.BaseRepo = BaseRepoFunc(f) // Depends on Remotes f.Prompter = newPrompter(f) // Depends on Config and IOStreams f.Browser = newBrowser(f) // Depends on Config, and IOStreams - f.GitClient = newGitClient(f) // Depends on IOStreams, and Executable f.ExtensionManager = extensionManager(f) // Depends on Config, HttpClient, and IOStreams + f.Branch = branchFunc(f) // Depends on GitClient return f } @@ -64,7 +65,7 @@ func SmartBaseRepoFunc(f *cmdutil.Factory) func() (ghrepo.Interface, error) { if err != nil { return nil, err } - repoContext, err := context.ResolveRemotesToRepos(remotes, apiClient, "") + repoContext, err := ghContext.ResolveRemotesToRepos(remotes, apiClient, "") if err != nil { return nil, err } @@ -77,10 +78,12 @@ func SmartBaseRepoFunc(f *cmdutil.Factory) func() (ghrepo.Interface, error) { } } -func remotesFunc(f *cmdutil.Factory) func() (context.Remotes, error) { +func remotesFunc(f *cmdutil.Factory) func() (ghContext.Remotes, error) { rr := &remoteResolver{ - readRemotes: git.Remotes, - getConfig: f.Config, + readRemotes: func() (git.RemoteSet, error) { + return f.GitClient.Remotes(context.Background()) + }, + getConfig: f.Config, } return rr.Resolver() } @@ -142,9 +145,9 @@ func configFunc() func() (config.Config, error) { } } -func branchFunc() func() (string, error) { +func branchFunc(f *cmdutil.Factory) func() (string, error) { return func() (string, error) { - currentBranch, err := git.CurrentBranch() + currentBranch, err := f.GitClient.CurrentBranch(context.Background()) if err != nil { return "", fmt.Errorf("could not determine current branch: %w", err) } diff --git a/pkg/cmd/gist/clone/clone.go b/pkg/cmd/gist/clone/clone.go index 41fa104fa..ed074e700 100644 --- a/pkg/cmd/gist/clone/clone.go +++ b/pkg/cmd/gist/clone/clone.go @@ -1,6 +1,7 @@ package clone import ( + "context" "fmt" "net/http" @@ -16,6 +17,7 @@ import ( type CloneOptions struct { HttpClient func() (*http.Client, error) + GitClient *git.Client Config func() (config.Config, error) IO *iostreams.IOStreams @@ -28,6 +30,7 @@ func NewCmdClone(f *cmdutil.Factory, runF func(*CloneOptions) error) *cobra.Comm opts := &CloneOptions{ IO: f.IOStreams, HttpClient: f.HttpClient, + GitClient: f.GitClient, Config: f.Config, } @@ -84,7 +87,7 @@ func cloneRun(opts *CloneOptions) error { gistURL = formatRemoteURL(hostname, gistURL, protocol) } - _, err := git.RunClone(gistURL, opts.GitArgs) + _, err := opts.GitClient.Clone(context.Background(), gistURL, opts.GitArgs) if err != nil { return err } diff --git a/pkg/cmd/gist/clone/clone_test.go b/pkg/cmd/gist/clone/clone_test.go index ccca76b25..cd6f71b6e 100644 --- a/pkg/cmd/gist/clone/clone_test.go +++ b/pkg/cmd/gist/clone/clone_test.go @@ -5,6 +5,7 @@ import ( "strings" "testing" + "github.com/cli/cli/v2/git" "github.com/cli/cli/v2/internal/config" "github.com/cli/cli/v2/internal/run" "github.com/cli/cli/v2/pkg/cmdutil" @@ -25,6 +26,7 @@ func runCloneCommand(httpClient *http.Client, cli string) (*test.CmdOut, error) Config: func() (config.Config, error) { return config.NewBlankConfig(), nil }, + GitClient: &git.Client{GitPath: "some/path/git"}, } cmd := NewCmdClone(fac, nil) diff --git a/pkg/cmd/pr/checkout/checkout.go b/pkg/cmd/pr/checkout/checkout.go index 73c8f40d1..8addfc4ae 100644 --- a/pkg/cmd/pr/checkout/checkout.go +++ b/pkg/cmd/pr/checkout/checkout.go @@ -19,6 +19,7 @@ import ( type CheckoutOptions struct { HttpClient func() (*http.Client, error) + GitClient *git.Client Config func() (config.Config, error) IO *iostreams.IOStreams Remotes func() (cliContext.Remotes, error) @@ -37,6 +38,7 @@ func NewCmdCheckout(f *cmdutil.Factory, runF func(*CheckoutOptions) error) *cobr opts := &CheckoutOptions{ IO: f.IOStreams, HttpClient: f.HttpClient, + GitClient: f.GitClient, Config: f.Config, Remotes: f.Remotes, Branch: f.Branch, @@ -124,11 +126,11 @@ func checkoutRun(opts *CheckoutOptions) error { } if opts.RecurseSubmodules { - cmdQueue = append(cmdQueue, []string{"git", "submodule", "sync", "--recursive"}) - cmdQueue = append(cmdQueue, []string{"git", "submodule", "update", "--init", "--recursive"}) + cmdQueue = append(cmdQueue, []string{"submodule", "sync", "--recursive"}) + cmdQueue = append(cmdQueue, []string{"submodule", "update", "--init", "--recursive"}) } - err = executeCmds(cmdQueue, opts.IO) + err = executeCmds(opts.GitClient, cmdQueue) if err != nil { return err } @@ -145,7 +147,7 @@ func cmdsForExistingRemote(remote *cliContext.Remote, pr *api.PullRequest, opts refSpec += fmt.Sprintf(":refs/remotes/%s", remoteBranch) } - cmds = append(cmds, []string{"git", "fetch", remote.Name, refSpec}) + cmds = append(cmds, []string{"fetch", remote.Name, refSpec}) localBranch := pr.HeadRefName if opts.BranchName != "" { @@ -154,17 +156,17 @@ func cmdsForExistingRemote(remote *cliContext.Remote, pr *api.PullRequest, opts switch { case opts.Detach: - cmds = append(cmds, []string{"git", "checkout", "--detach", "FETCH_HEAD"}) - case localBranchExists(localBranch): - cmds = append(cmds, []string{"git", "checkout", localBranch}) + cmds = append(cmds, []string{"checkout", "--detach", "FETCH_HEAD"}) + case localBranchExists(opts.GitClient, localBranch): + cmds = append(cmds, []string{"checkout", localBranch}) if opts.Force { - cmds = append(cmds, []string{"git", "reset", "--hard", fmt.Sprintf("refs/remotes/%s", remoteBranch)}) + cmds = append(cmds, []string{"reset", "--hard", fmt.Sprintf("refs/remotes/%s", remoteBranch)}) } else { // TODO: check if non-fast-forward and suggest to use `--force` - cmds = append(cmds, []string{"git", "merge", "--ff-only", fmt.Sprintf("refs/remotes/%s", remoteBranch)}) + cmds = append(cmds, []string{"merge", "--ff-only", fmt.Sprintf("refs/remotes/%s", remoteBranch)}) } default: - cmds = append(cmds, []string{"git", "checkout", "-b", localBranch, "--track", remoteBranch}) + cmds = append(cmds, []string{"checkout", "-b", localBranch, "--track", remoteBranch}) } return cmds @@ -175,8 +177,8 @@ func cmdsForMissingRemote(pr *api.PullRequest, baseURLOrName, repoHost, defaultB ref := fmt.Sprintf("refs/pull/%d/head", pr.Number) if opts.Detach { - cmds = append(cmds, []string{"git", "fetch", baseURLOrName, ref}) - cmds = append(cmds, []string{"git", "checkout", "--detach", "FETCH_HEAD"}) + cmds = append(cmds, []string{"fetch", baseURLOrName, ref}) + cmds = append(cmds, []string{"checkout", "--detach", "FETCH_HEAD"}) return cmds } @@ -191,22 +193,22 @@ func cmdsForMissingRemote(pr *api.PullRequest, baseURLOrName, repoHost, defaultB currentBranch, _ := opts.Branch() if localBranch == currentBranch { // PR head matches currently checked out branch - cmds = append(cmds, []string{"git", "fetch", baseURLOrName, ref}) + cmds = append(cmds, []string{"fetch", baseURLOrName, ref}) if opts.Force { - cmds = append(cmds, []string{"git", "reset", "--hard", "FETCH_HEAD"}) + cmds = append(cmds, []string{"reset", "--hard", "FETCH_HEAD"}) } else { // TODO: check if non-fast-forward and suggest to use `--force` - cmds = append(cmds, []string{"git", "merge", "--ff-only", "FETCH_HEAD"}) + cmds = append(cmds, []string{"merge", "--ff-only", "FETCH_HEAD"}) } } else { if opts.Force { - cmds = append(cmds, []string{"git", "fetch", baseURLOrName, fmt.Sprintf("%s:%s", ref, localBranch), "--force"}) + cmds = append(cmds, []string{"fetch", baseURLOrName, fmt.Sprintf("%s:%s", ref, localBranch), "--force"}) } else { // TODO: check if non-fast-forward and suggest to use `--force` - cmds = append(cmds, []string{"git", "fetch", baseURLOrName, fmt.Sprintf("%s:%s", ref, localBranch)}) + cmds = append(cmds, []string{"fetch", baseURLOrName, fmt.Sprintf("%s:%s", ref, localBranch)}) } - cmds = append(cmds, []string{"git", "checkout", localBranch}) + cmds = append(cmds, []string{"checkout", localBranch}) } remote := baseURLOrName @@ -216,37 +218,32 @@ func cmdsForMissingRemote(pr *api.PullRequest, baseURLOrName, repoHost, defaultB remote = ghrepo.FormatRemoteURL(headRepo, protocol) mergeRef = fmt.Sprintf("refs/heads/%s", pr.HeadRefName) } - if missingMergeConfigForBranch(localBranch) { + if missingMergeConfigForBranch(opts.GitClient, localBranch) { // .remote is needed for `git pull` to work // .pushRemote is needed for `git push` to work, if user has set `remote.pushDefault`. // see https://git-scm.com/docs/git-config#Documentation/git-config.txt-branchltnamegtremote - cmds = append(cmds, []string{"git", "config", fmt.Sprintf("branch.%s.remote", localBranch), remote}) - cmds = append(cmds, []string{"git", "config", fmt.Sprintf("branch.%s.pushRemote", localBranch), remote}) - cmds = append(cmds, []string{"git", "config", fmt.Sprintf("branch.%s.merge", localBranch), mergeRef}) + cmds = append(cmds, []string{"config", fmt.Sprintf("branch.%s.remote", localBranch), remote}) + cmds = append(cmds, []string{"config", fmt.Sprintf("branch.%s.pushRemote", localBranch), remote}) + cmds = append(cmds, []string{"config", fmt.Sprintf("branch.%s.merge", localBranch), mergeRef}) } return cmds } -func missingMergeConfigForBranch(b string) bool { - mc, err := git.Config(fmt.Sprintf("branch.%s.merge", b)) +func missingMergeConfigForBranch(client *git.Client, b string) bool { + mc, err := client.Config(context.Background(), fmt.Sprintf("branch.%s.merge", b)) return err != nil || mc == "" } -func localBranchExists(b string) bool { - _, err := git.ShowRefs("refs/heads/" + b) +func localBranchExists(client *git.Client, b string) bool { + _, err := client.ShowRefs(context.Background(), "refs/heads/"+b) return err == nil } -func executeCmds(cmdQueue [][]string, ios *iostreams.IOStreams) error { - //TODO: Replace with factory GitClient - //TODO: Use AuthenticatedCommand - client := git.Client{ - Stdout: ios.Out, - Stderr: ios.ErrOut, - } +func executeCmds(client *git.Client, cmdQueue [][]string) error { for _, args := range cmdQueue { - cmd, err := client.Command(context.Background(), args[1:]...) + //TODO: Use AuthenticatedCommand + cmd, err := client.Command(context.Background(), args...) if err != nil { return err } diff --git a/pkg/cmd/pr/checkout/checkout_test.go b/pkg/cmd/pr/checkout/checkout_test.go index df77e6676..7540bbacf 100644 --- a/pkg/cmd/pr/checkout/checkout_test.go +++ b/pkg/cmd/pr/checkout/checkout_test.go @@ -197,6 +197,8 @@ func Test_checkoutRun(t *testing.T) { return remotes, nil } + opts.GitClient = &git.Client{GitPath: "some/path/git"} + err := checkoutRun(opts) if (err != nil) != tt.wantErr { t.Errorf("want error: %v, got: %v", tt.wantErr, err) @@ -234,6 +236,7 @@ func runCommand(rt http.RoundTripper, remotes context.Remotes, branch string, cl Branch: func() (string, error) { return branch, nil }, + GitClient: &git.Client{GitPath: "some/path/git"}, } cmd := NewCmdCheckout(factory, nil) diff --git a/pkg/cmd/pr/close/close.go b/pkg/cmd/pr/close/close.go index 7d80199b6..7e39005bd 100644 --- a/pkg/cmd/pr/close/close.go +++ b/pkg/cmd/pr/close/close.go @@ -1,6 +1,7 @@ package close import ( + "context" "fmt" "net/http" @@ -15,6 +16,7 @@ import ( type CloseOptions struct { HttpClient func() (*http.Client, error) + GitClient *git.Client IO *iostreams.IOStreams Branch func() (string, error) @@ -30,6 +32,7 @@ func NewCmdClose(f *cmdutil.Factory, runF func(*CloseOptions) error) *cobra.Comm opts := &CloseOptions{ IO: f.IOStreams, HttpClient: f.HttpClient, + GitClient: f.GitClient, Branch: f.Branch, } @@ -108,9 +111,10 @@ func closeRun(opts *CloseOptions) error { fmt.Fprintf(opts.IO.ErrOut, "%s Closed pull request #%d (%s)\n", cs.SuccessIconWithColor(cs.Red), pr.Number, pr.Title) if opts.DeleteBranch { + ctx := context.Background() branchSwitchString := "" apiClient := api.NewClientFromHTTP(httpClient) - localBranchExists := git.HasLocalBranch(pr.HeadRefName) + localBranchExists := opts.GitClient.HasLocalBranch(ctx, pr.HeadRefName) if opts.DeleteLocalBranch { if localBranchExists { @@ -125,13 +129,13 @@ func closeRun(opts *CloseOptions) error { if err != nil { return err } - err = git.CheckoutBranch(branchToSwitchTo) + err = opts.GitClient.CheckoutBranch(ctx, branchToSwitchTo) if err != nil { return err } } - if err := git.DeleteLocalBranch(pr.HeadRefName); err != nil { + if err := opts.GitClient.DeleteLocalBranch(ctx, pr.HeadRefName); err != nil { return fmt.Errorf("failed to delete local branch %s: %w", cs.Cyan(pr.HeadRefName), err) } diff --git a/pkg/cmd/pr/close/close_test.go b/pkg/cmd/pr/close/close_test.go index df9585fbb..b435fb8aa 100644 --- a/pkg/cmd/pr/close/close_test.go +++ b/pkg/cmd/pr/close/close_test.go @@ -9,6 +9,7 @@ import ( "github.com/MakeNowJust/heredoc" "github.com/cli/cli/v2/api" + "github.com/cli/cli/v2/git" "github.com/cli/cli/v2/internal/ghrepo" "github.com/cli/cli/v2/internal/run" "github.com/cli/cli/v2/pkg/cmd/pr/shared" @@ -72,6 +73,7 @@ func runCommand(rt http.RoundTripper, isTTY bool, cli string) (*test.CmdOut, err Branch: func() (string, error) { return "trunk", nil }, + GitClient: &git.Client{GitPath: "some/path/git"}, } cmd := NewCmdClose(factory, nil) diff --git a/pkg/cmd/pr/create/create.go b/pkg/cmd/pr/create/create.go index e9c664b88..75d4b58f1 100644 --- a/pkg/cmd/pr/create/create.go +++ b/pkg/cmd/pr/create/create.go @@ -1,6 +1,7 @@ package create import ( + "context" "errors" "fmt" "net/http" @@ -12,7 +13,7 @@ import ( "github.com/AlecAivazis/survey/v2" "github.com/MakeNowJust/heredoc" "github.com/cli/cli/v2/api" - "github.com/cli/cli/v2/context" + ghContext "github.com/cli/cli/v2/context" "github.com/cli/cli/v2/git" "github.com/cli/cli/v2/internal/browser" "github.com/cli/cli/v2/internal/config" @@ -32,9 +33,10 @@ type iprompter interface { type CreateOptions struct { // This struct stores user input and factory functions HttpClient func() (*http.Client, error) + GitClient *git.Client Config func() (config.Config, error) IO *iostreams.IOStreams - Remotes func() (context.Remotes, error) + Remotes func() (ghContext.Remotes, error) Branch func() (string, error) Browser browser.Browser Prompter iprompter @@ -68,22 +70,24 @@ type CreateOptions struct { type CreateContext struct { // This struct stores contextual data about the creation process and is for building up enough // data to create a pull request - RepoContext *context.ResolvedRemotes + RepoContext *ghContext.ResolvedRemotes BaseRepo *api.Repository HeadRepo ghrepo.Interface BaseTrackingBranch string BaseBranch string HeadBranch string HeadBranchLabel string - HeadRemote *context.Remote + HeadRemote *ghContext.Remote IsPushEnabled bool Client *api.Client + GitClient *git.Client } func NewCmdCreate(f *cmdutil.Factory, runF func(*CreateOptions) error) *cobra.Command { opts := &CreateOptions{ IO: f.IOStreams, HttpClient: f.HttpClient, + GitClient: f.GitClient, Config: f.Config, Remotes: f.Remotes, Branch: f.Branch, @@ -369,15 +373,16 @@ func createRun(opts *CreateOptions) (err error) { func initDefaultTitleBody(ctx CreateContext, state *shared.IssueMetadataState) error { baseRef := ctx.BaseTrackingBranch headRef := ctx.HeadBranch + gitClient := ctx.GitClient - commits, err := git.Commits(baseRef, headRef) + commits, err := gitClient.Commits(context.Background(), baseRef, headRef) if err != nil { return err } if len(commits) == 1 { state.Title = commits[0].Title - body, err := git.CommitBody(commits[0].Sha) + body, err := gitClient.CommitBody(context.Background(), commits[0].Sha) if err != nil { return err } @@ -395,11 +400,11 @@ func initDefaultTitleBody(ctx CreateContext, state *shared.IssueMetadataState) e return nil } -func determineTrackingBranch(remotes context.Remotes, headBranch string) *git.TrackingRef { +func determineTrackingBranch(gitClient *git.Client, remotes ghContext.Remotes, headBranch string) *git.TrackingRef { refsForLookup := []string{"HEAD"} var trackingRefs []git.TrackingRef - headBranchConfig := git.ReadBranchConfig(headBranch) + headBranchConfig := gitClient.ReadBranchConfig(context.Background(), headBranch) if headBranchConfig.RemoteName != "" { tr := git.TrackingRef{ RemoteName: headBranchConfig.RemoteName, @@ -418,7 +423,7 @@ func determineTrackingBranch(remotes context.Remotes, headBranch string) *git.Tr refsForLookup = append(refsForLookup, tr.String()) } - resolvedRefs, _ := git.ShowRefs(refsForLookup...) + resolvedRefs, _ := gitClient.ShowRefs(context.Background(), refsForLookup...) if len(resolvedRefs) > 1 { for _, r := range resolvedRefs[1:] { if r.Hash != resolvedRefs[0].Hash { @@ -480,7 +485,7 @@ func NewCreateContext(opts *CreateOptions) (*CreateContext, error) { return nil, err } - repoContext, err := context.ResolveRemotesToRepos(remotes, client, opts.RepoOverride) + repoContext, err := ghContext.ResolveRemotesToRepos(remotes, client, opts.RepoOverride) if err != nil { return nil, err } @@ -515,16 +520,17 @@ func NewCreateContext(opts *CreateOptions) (*CreateContext, error) { headBranch = headBranch[idx+1:] } - if ucc, err := git.UncommittedChangeCount(); err == nil && ucc > 0 { + gitClient := opts.GitClient + if ucc, err := gitClient.UncommittedChangeCount(context.Background()); err == nil && ucc > 0 { fmt.Fprintf(opts.IO.ErrOut, "Warning: %s\n", text.Pluralize(ucc, "uncommitted change")) } var headRepo ghrepo.Interface - var headRemote *context.Remote + var headRemote *ghContext.Remote if isPushEnabled { // determine whether the head branch is already pushed to a remote - if pushedTo := determineTrackingBranch(remotes, headBranch); pushedTo != nil { + if pushedTo := determineTrackingBranch(gitClient, remotes, headBranch); pushedTo != nil { isPushEnabled = false if r, err := remotes.FindByName(pushedTo.RemoteName); err == nil { headRepo = r @@ -625,6 +631,7 @@ func NewCreateContext(opts *CreateOptions) (*CreateContext, error) { IsPushEnabled: isPushEnabled, RepoContext: repoContext, Client: client, + GitClient: gitClient, }, nil } @@ -713,11 +720,12 @@ func handlePush(opts CreateOptions, ctx CreateContext) error { headRepoURL := ghrepo.FormatRemoteURL(headRepo, cloneProtocol) // TODO: prevent clashes with another remote of a same name - gitRemote, err := git.AddRemote("fork", headRepoURL) + gitClient := ctx.GitClient + gitRemote, err := gitClient.AddRemote(context.Background(), "fork", headRepoURL, []string{}) if err != nil { return fmt.Errorf("error adding remote: %w", err) } - headRemote = &context.Remote{ + headRemote = &ghContext.Remote{ Remote: gitRemote, Repo: headRepo, } @@ -729,12 +737,11 @@ func handlePush(opts CreateOptions, ctx CreateContext) error { pushTries := 0 maxPushTries := 3 for { - r := NewRegexpWriter(opts.IO.ErrOut, gitPushRegexp, "") - defer r.Flush() - cmdErr := r - cmdIn := opts.IO.In - cmdOut := opts.IO.Out - if err := git.Push(headRemote.Name, fmt.Sprintf("HEAD:%s", ctx.HeadBranch), cmdIn, cmdOut, cmdErr); err != nil { + w := NewRegexpWriter(opts.IO.ErrOut, gitPushRegexp, "") + defer w.Flush() + gitClient := ctx.GitClient + ref := fmt.Sprintf("HEAD:%s", ctx.HeadBranch) + if err := gitClient.Push(context.Background(), headRemote.Name, ref, git.WithStderr(w)); err != nil { if didForkRepo && pushTries < maxPushTries { pushTries++ // first wait 2 seconds after forking, then 4s, then 6s diff --git a/pkg/cmd/pr/create/create_test.go b/pkg/cmd/pr/create/create_test.go index c83dd13a2..0764ffe3b 100644 --- a/pkg/cmd/pr/create/create_test.go +++ b/pkg/cmd/pr/create/create_test.go @@ -869,6 +869,7 @@ func Test_createRun(t *testing.T) { return branch, nil } opts.Finder = shared.NewMockFinder(branch, nil, nil) + opts.GitClient = &git.Client{GitPath: "some/path/git"} cleanSetup := func() {} if tt.setup != nil { cleanSetup = tt.setup(&opts, t) @@ -985,7 +986,8 @@ func Test_determineTrackingBranch(t *testing.T) { tt.cmdStubs(cs) - ref := determineTrackingBranch(tt.remotes, "feature") + gitClient := &git.Client{GitPath: "some/path/git"} + ref := determineTrackingBranch(gitClient, tt.remotes, "feature") tt.assert(ref, t) }) } diff --git a/pkg/cmd/pr/merge/merge.go b/pkg/cmd/pr/merge/merge.go index 2972c09f9..dbf0923f7 100644 --- a/pkg/cmd/pr/merge/merge.go +++ b/pkg/cmd/pr/merge/merge.go @@ -1,6 +1,7 @@ package merge import ( + "context" "errors" "fmt" "net/http" @@ -8,7 +9,7 @@ import ( "github.com/AlecAivazis/survey/v2" "github.com/MakeNowJust/heredoc" "github.com/cli/cli/v2/api" - "github.com/cli/cli/v2/context" + ghContext "github.com/cli/cli/v2/context" "github.com/cli/cli/v2/git" "github.com/cli/cli/v2/internal/config" "github.com/cli/cli/v2/internal/ghrepo" @@ -26,9 +27,10 @@ type editor interface { type MergeOptions struct { HttpClient func() (*http.Client, error) + GitClient *git.Client IO *iostreams.IOStreams Branch func() (string, error) - Remotes func() (context.Remotes, error) + Remotes func() (ghContext.Remotes, error) Finder shared.PRFinder @@ -60,6 +62,7 @@ func NewCmdMerge(f *cmdutil.Factory, runF func(*MergeOptions) error) *cobra.Comm opts := &MergeOptions{ IO: f.IOStreams, HttpClient: f.HttpClient, + GitClient: f.GitClient, Branch: f.Branch, Remotes: f.Remotes, } @@ -224,7 +227,7 @@ func (m *mergeContext) warnIfDiverged() { return } - localBranchLastCommit, err := git.LastCommit() + localBranchLastCommit, err := m.opts.GitClient.LastCommit(context.Background()) if err != nil { return } @@ -396,6 +399,8 @@ func (m *mergeContext) deleteLocalBranch() error { return err } + ctx := context.Background() + // branch the command was run on is the same as the pull request branch if currentBranch == m.pr.HeadRefName { remotes, err := m.opts.Remotes() @@ -409,24 +414,24 @@ func (m *mergeContext) deleteLocalBranch() error { } targetBranch := m.pr.BaseRefName - if git.HasLocalBranch(targetBranch) { - if err := git.CheckoutBranch(targetBranch); err != nil { + if m.opts.GitClient.HasLocalBranch(ctx, targetBranch) { + if err := m.opts.GitClient.CheckoutBranch(ctx, targetBranch); err != nil { return err } } else { - if err := git.CheckoutNewBranch(baseRemote.Name, targetBranch); err != nil { + if err := m.opts.GitClient.CheckoutNewBranch(ctx, baseRemote.Name, targetBranch); err != nil { return err } } - if err := git.Pull(baseRemote.Name, targetBranch); err != nil { + if err := m.opts.GitClient.Pull(ctx, baseRemote.Name, targetBranch); err != nil { _ = m.warnf(fmt.Sprintf("%s warning: not possible to fast-forward to: %q\n", m.cs.WarningIcon(), targetBranch)) } m.switchedToBranch = targetBranch } - if err := git.DeleteLocalBranch(m.pr.HeadRefName); err != nil { + if err := m.opts.GitClient.DeleteLocalBranch(ctx, m.pr.HeadRefName); err != nil { return fmt.Errorf("failed to delete local branch %s: %w", m.cs.Cyan(m.pr.HeadRefName), err) } @@ -503,7 +508,7 @@ func NewMergeContext(opts *MergeOptions) (*mergeContext, error) { deleteBranch: opts.DeleteBranch, crossRepoPR: pr.HeadRepositoryOwner.Login != baseRepo.RepoOwner(), autoMerge: opts.AutoMergeEnable && !isImmediatelyMergeable(pr.MergeStateStatus), - localBranchExists: opts.CanDeleteLocalBranch && git.HasLocalBranch(pr.HeadRefName), + localBranchExists: opts.CanDeleteLocalBranch && opts.GitClient.HasLocalBranch(context.Background(), pr.HeadRefName), mergeQueueRequired: pr.IsMergeQueueEnabled, }, nil } @@ -730,7 +735,7 @@ func allowsAdminOverride(status string) bool { } } -func remoteForMergeConflictResolution(baseRepo ghrepo.Interface, pr *api.PullRequest, opts *MergeOptions) *context.Remote { +func remoteForMergeConflictResolution(baseRepo ghrepo.Interface, pr *api.PullRequest, opts *MergeOptions) *ghContext.Remote { if !mergeConflictStatus(pr.MergeStateStatus) || !opts.CanDeleteLocalBranch { return nil } diff --git a/pkg/cmd/pr/merge/merge_test.go b/pkg/cmd/pr/merge/merge_test.go index d1bb21d8c..0e3ced350 100644 --- a/pkg/cmd/pr/merge/merge_test.go +++ b/pkg/cmd/pr/merge/merge_test.go @@ -270,6 +270,7 @@ func runCommand(rt http.RoundTripper, branch string, isTTY bool, cli string) (*t }, }, nil }, + GitClient: &git.Client{GitPath: "some/path/git"}, } cmd := NewCmdMerge(factory, nil) diff --git a/pkg/cmd/pr/shared/finder.go b/pkg/cmd/pr/shared/finder.go index b700a7501..ed04c4295 100644 --- a/pkg/cmd/pr/shared/finder.go +++ b/pkg/cmd/pr/shared/finder.go @@ -53,12 +53,14 @@ func NewFinder(factory *cmdutil.Factory) PRFinder { } return &finder{ - baseRepoFn: factory.BaseRepo, - branchFn: factory.Branch, - remotesFn: factory.Remotes, - httpClient: factory.HttpClient, - progress: factory.IOStreams, - branchConfig: git.ReadBranchConfig, + baseRepoFn: factory.BaseRepo, + branchFn: factory.Branch, + remotesFn: factory.Remotes, + httpClient: factory.HttpClient, + progress: factory.IOStreams, + branchConfig: func(s string) git.BranchConfig { + return factory.GitClient.ReadBranchConfig(context.Background(), s) + }, } } diff --git a/pkg/cmd/pr/shared/survey.go b/pkg/cmd/pr/shared/survey.go index 31942c6a0..dd4ba085d 100644 --- a/pkg/cmd/pr/shared/survey.go +++ b/pkg/cmd/pr/shared/survey.go @@ -6,9 +6,7 @@ import ( "github.com/AlecAivazis/survey/v2" "github.com/cli/cli/v2/api" - "github.com/cli/cli/v2/git" "github.com/cli/cli/v2/internal/ghrepo" - "github.com/cli/cli/v2/pkg/githubtemplate" "github.com/cli/cli/v2/pkg/iostreams" "github.com/cli/cli/v2/pkg/prompt" "github.com/cli/cli/v2/pkg/surveyext" @@ -369,18 +367,3 @@ func MetadataSurvey(io *iostreams.IOStreams, baseRepo ghrepo.Interface, fetcher return nil } - -func FindTemplates(dir, path string) ([]string, string) { - if dir == "" { - rootDir, err := git.ToplevelDir() - if err != nil { - return []string{}, "" - } - dir = rootDir - } - - templateFiles := githubtemplate.FindNonLegacy(dir, path) - legacyTemplate := githubtemplate.FindLegacy(dir, path) - - return templateFiles, legacyTemplate -} diff --git a/pkg/cmd/pr/shared/templates.go b/pkg/cmd/pr/shared/templates.go index 975bdd5da..7aab57978 100644 --- a/pkg/cmd/pr/shared/templates.go +++ b/pkg/cmd/pr/shared/templates.go @@ -1,6 +1,7 @@ package shared import ( + "context" "fmt" "net/http" "time" @@ -233,7 +234,8 @@ func (m *templateManager) fetch() error { dir := m.rootDir if dir == "" { var err error - dir, err = git.ToplevelDir() + gitClient := &git.Client{} + dir, err = gitClient.ToplevelDir(context.Background()) if err != nil { return nil // abort silently } diff --git a/pkg/cmd/pr/status/status.go b/pkg/cmd/pr/status/status.go index 784159564..e4434978b 100644 --- a/pkg/cmd/pr/status/status.go +++ b/pkg/cmd/pr/status/status.go @@ -1,6 +1,7 @@ package status import ( + "context" "errors" "fmt" "net/http" @@ -9,7 +10,7 @@ import ( "strings" "github.com/cli/cli/v2/api" - "github.com/cli/cli/v2/context" + ghContext "github.com/cli/cli/v2/context" "github.com/cli/cli/v2/git" "github.com/cli/cli/v2/internal/config" "github.com/cli/cli/v2/internal/ghrepo" @@ -22,10 +23,11 @@ import ( type StatusOptions struct { HttpClient func() (*http.Client, error) + GitClient *git.Client Config func() (config.Config, error) IO *iostreams.IOStreams BaseRepo func() (ghrepo.Interface, error) - Remotes func() (context.Remotes, error) + Remotes func() (ghContext.Remotes, error) Branch func() (string, error) HasRepoOverride bool @@ -37,6 +39,7 @@ func NewCmdStatus(f *cmdutil.Factory, runF func(*StatusOptions) error) *cobra.Co opts := &StatusOptions{ IO: f.IOStreams, HttpClient: f.HttpClient, + GitClient: f.GitClient, Config: f.Config, Remotes: f.Remotes, Branch: f.Branch, @@ -86,7 +89,7 @@ func statusRun(opts *StatusOptions) error { } remotes, _ := opts.Remotes() - currentPRNumber, currentPRHeadRef, err = prSelectorForCurrentBranch(baseRepo, currentBranch, remotes) + currentPRNumber, currentPRHeadRef, err = prSelectorForCurrentBranch(opts.GitClient, baseRepo, currentBranch, remotes) if err != nil { return fmt.Errorf("could not query for pull request for current branch: %w", err) } @@ -165,9 +168,9 @@ func statusRun(opts *StatusOptions) error { return nil } -func prSelectorForCurrentBranch(baseRepo ghrepo.Interface, prHeadRef string, rem context.Remotes) (prNumber int, selector string, err error) { +func prSelectorForCurrentBranch(gitClient *git.Client, baseRepo ghrepo.Interface, prHeadRef string, rem ghContext.Remotes) (prNumber int, selector string, err error) { selector = prHeadRef - branchConfig := git.ReadBranchConfig(prHeadRef) + branchConfig := gitClient.ReadBranchConfig(context.Background(), prHeadRef) // the branch is configured to merge a special PR head ref prHeadRE := regexp.MustCompile(`^refs/pull/(\d+)/head$`) diff --git a/pkg/cmd/pr/status/status_test.go b/pkg/cmd/pr/status/status_test.go index 31f28396e..c3187739d 100644 --- a/pkg/cmd/pr/status/status_test.go +++ b/pkg/cmd/pr/status/status_test.go @@ -52,6 +52,7 @@ func runCommand(rt http.RoundTripper, branch string, isTTY bool, cli string) (*t } return branch, nil }, + GitClient: &git.Client{GitPath: "some/path/git"}, } cmd := NewCmdStatus(factory, nil) @@ -328,7 +329,8 @@ func Test_prSelectorForCurrentBranch(t *testing.T) { Repo: repo, }, } - prNum, headRef, err := prSelectorForCurrentBranch(repo, "Frederick888/main", rem) + gitClient := &git.Client{GitPath: "some/path/git"} + prNum, headRef, err := prSelectorForCurrentBranch(gitClient, repo, "Frederick888/main", rem) if err != nil { t.Fatalf("prSelectorForCurrentBranch error: %v", err) } diff --git a/pkg/cmd/release/create/create.go b/pkg/cmd/release/create/create.go index e6f97dd9f..8c36218da 100644 --- a/pkg/cmd/release/create/create.go +++ b/pkg/cmd/release/create/create.go @@ -2,6 +2,7 @@ package create import ( "bytes" + "context" "errors" "fmt" "io" @@ -26,6 +27,7 @@ type CreateOptions struct { IO *iostreams.IOStreams Config func() (config.Config, error) HttpClient func() (*http.Client, error) + GitClient *git.Client BaseRepo func() (ghrepo.Interface, error) Edit func(string, string, string, io.Reader, io.Writer, io.Writer) (string, error) @@ -56,6 +58,7 @@ func NewCmdCreate(f *cmdutil.Factory, runF func(*CreateOptions) error) *cobra.Co opts := &CreateOptions{ IO: f.IOStreams, HttpClient: f.HttpClient, + GitClient: f.GitClient, Config: f.Config, Edit: surveyext.Edit, } @@ -221,7 +224,7 @@ func createRun(opts *CreateOptions) error { var tagDescription string if opts.RepoOverride == "" { - tagDescription, _ = gitTagInfo(opts.TagName) + tagDescription, _ = gitTagInfo(opts.GitClient, opts.TagName) // If there is a local tag with the same name as specified // the user may not want to create a new tag on the remote // as the local one might be annotated or signed. @@ -268,10 +271,10 @@ func createRun(opts *CreateOptions) error { } if generatedNotes == nil { if opts.NotesStartTag != "" { - commits, _ := changelogForRange(fmt.Sprintf("%s..%s", opts.NotesStartTag, headRef)) + commits, _ := changelogForRange(opts.GitClient, fmt.Sprintf("%s..%s", opts.NotesStartTag, headRef)) generatedChangelog = generateChangelog(commits) - } else if prevTag, err := detectPreviousTag(headRef); err == nil { - commits, _ := changelogForRange(fmt.Sprintf("%s..%s", prevTag, headRef)) + } else if prevTag, err := detectPreviousTag(opts.GitClient, headRef); err == nil { + commits, _ := changelogForRange(opts.GitClient, fmt.Sprintf("%s..%s", prevTag, headRef)) generatedChangelog = generateChangelog(commits) } } @@ -469,8 +472,8 @@ func createRun(opts *CreateOptions) error { return nil } -func gitTagInfo(tagName string) (string, error) { - cmd, err := git.GitCommand("tag", "--list", tagName, "--format=%(contents:subject)%0a%0a%(contents:body)") +func gitTagInfo(client *git.Client, tagName string) (string, error) { + cmd, err := client.Command(context.Background(), "tag", "--list", tagName, "--format=%(contents:subject)%0a%0a%(contents:body)") if err != nil { return "", err } @@ -478,8 +481,8 @@ func gitTagInfo(tagName string) (string, error) { return string(b), err } -func detectPreviousTag(headRef string) (string, error) { - cmd, err := git.GitCommand("describe", "--tags", "--abbrev=0", fmt.Sprintf("%s^", headRef)) +func detectPreviousTag(client *git.Client, headRef string) (string, error) { + cmd, err := client.Command(context.Background(), "describe", "--tags", "--abbrev=0", fmt.Sprintf("%s^", headRef)) if err != nil { return "", err } @@ -492,8 +495,8 @@ type logEntry struct { Body string } -func changelogForRange(refRange string) ([]logEntry, error) { - cmd, err := git.GitCommand("-c", "log.ShowSignature=false", "log", "--first-parent", "--reverse", "--pretty=format:%B%x00", refRange) +func changelogForRange(client *git.Client, refRange string) ([]logEntry, error) { + cmd, err := client.Command(context.Background(), "-c", "log.ShowSignature=false", "log", "--first-parent", "--reverse", "--pretty=format:%B%x00", refRange) if err != nil { return nil, err } diff --git a/pkg/cmd/release/create/create_test.go b/pkg/cmd/release/create/create_test.go index 7fb6711b2..a6424c0d6 100644 --- a/pkg/cmd/release/create/create_test.go +++ b/pkg/cmd/release/create/create_test.go @@ -10,6 +10,7 @@ import ( "path/filepath" "testing" + "github.com/cli/cli/v2/git" "github.com/cli/cli/v2/internal/config" "github.com/cli/cli/v2/internal/ghrepo" "github.com/cli/cli/v2/internal/run" @@ -688,6 +689,8 @@ func Test_createRun(t *testing.T) { return ghrepo.FromFullName("OWNER/REPO") } + tt.opts.GitClient = &git.Client{GitPath: "some/path/git"} + err := createRun(&tt.opts) if tt.wantErr != "" { require.EqualError(t, err, tt.wantErr) @@ -1050,6 +1053,8 @@ func Test_createRun_interactive(t *testing.T) { return val, nil } + tt.opts.GitClient = &git.Client{GitPath: "some/path/git"} + t.Run(tt.name, func(t *testing.T) { //nolint:staticcheck // SA1019: prompt.NewAskStubber is deprecated: use PrompterMock as := prompt.NewAskStubber(t) diff --git a/pkg/cmd/repo/clone/clone.go b/pkg/cmd/repo/clone/clone.go index ba289aa47..cb46d4572 100644 --- a/pkg/cmd/repo/clone/clone.go +++ b/pkg/cmd/repo/clone/clone.go @@ -1,6 +1,7 @@ package clone import ( + "context" "fmt" "net/http" "strings" @@ -18,6 +19,7 @@ import ( type CloneOptions struct { HttpClient func() (*http.Client, error) + GitClient *git.Client Config func() (config.Config, error) IO *iostreams.IOStreams @@ -30,6 +32,7 @@ func NewCmdClone(f *cmdutil.Factory, runF func(*CloneOptions) error) *cobra.Comm opts := &CloneOptions{ IO: f.IOStreams, HttpClient: f.HttpClient, + GitClient: f.GitClient, Config: f.Config, } @@ -152,7 +155,9 @@ func cloneRun(opts *CloneOptions) error { canonicalCloneURL = strings.TrimSuffix(canonicalCloneURL, ".git") + ".wiki.git" } - cloneDir, err := git.RunClone(canonicalCloneURL, opts.GitArgs) + gitClient := opts.GitClient + ctx := context.Background() + cloneDir, err := gitClient.Clone(ctx, canonicalCloneURL, opts.GitArgs) if err != nil { return err } @@ -170,7 +175,7 @@ func cloneRun(opts *CloneOptions) error { upstreamName = canonicalRepo.Parent.RepoOwner() } - err = git.AddNamedRemote(upstreamURL, upstreamName, cloneDir, []string{canonicalRepo.Parent.DefaultBranchRef.Name}) + _, err = gitClient.AddRemote(ctx, upstreamName, upstreamURL, []string{canonicalRepo.Parent.DefaultBranchRef.Name}, git.WithRepoDir(cloneDir)) if err != nil { return err } diff --git a/pkg/cmd/repo/clone/clone_test.go b/pkg/cmd/repo/clone/clone_test.go index 34f2fe431..c6ec872be 100644 --- a/pkg/cmd/repo/clone/clone_test.go +++ b/pkg/cmd/repo/clone/clone_test.go @@ -5,6 +5,7 @@ import ( "strings" "testing" + "github.com/cli/cli/v2/git" "github.com/cli/cli/v2/internal/config" "github.com/cli/cli/v2/internal/run" "github.com/cli/cli/v2/pkg/cmdutil" @@ -104,6 +105,7 @@ func runCloneCommand(httpClient *http.Client, cli string) (*test.CmdOut, error) Config: func() (config.Config, error) { return config.NewBlankConfig(), nil }, + GitClient: &git.Client{GitPath: "some/path/git"}, } cmd := NewCmdClone(fac, nil) diff --git a/pkg/cmd/repo/create/create.go b/pkg/cmd/repo/create/create.go index e311ad727..931ffb926 100644 --- a/pkg/cmd/repo/create/create.go +++ b/pkg/cmd/repo/create/create.go @@ -1,6 +1,7 @@ package create import ( + "context" "errors" "fmt" "net/http" @@ -27,6 +28,7 @@ type iprompter interface { type CreateOptions struct { HttpClient func() (*http.Client, error) + GitClient *git.Client Config func() (config.Config, error) IO *iostreams.IOStreams Prompter iprompter @@ -57,6 +59,7 @@ func NewCmdCreate(f *cmdutil.Factory, runF func(*CreateOptions) error) *cobra.Co opts := &CreateOptions{ IO: f.IOStreams, HttpClient: f.HttpClient, + GitClient: f.GitClient, Config: f.Config, Prompter: f.Prompter, } @@ -390,10 +393,10 @@ func createFromScratch(opts *CreateOptions) error { // use the template's default branch checkoutBranch = templateRepoMainBranch } - if err := localInit(opts.IO, remoteURL, repo.RepoName(), checkoutBranch); err != nil { + if err := localInit(opts.GitClient, remoteURL, repo.RepoName(), checkoutBranch); err != nil { return err } - } else if _, err := git.RunClone(remoteURL, []string{}); err != nil { + } else if _, err := opts.GitClient.Clone(context.Background(), remoteURL, []string{}); err != nil { return err } } @@ -427,6 +430,7 @@ func createFromLocal(opts *CreateOptions) error { } repoPath := opts.Source + opts.GitClient.RepoDir = repoPath var baseRemote string if opts.Remote == "" { @@ -440,7 +444,7 @@ func createFromLocal(opts *CreateOptions) error { return err } - isRepo, err := isLocalRepo(repoPath) + isRepo, err := isLocalRepo(opts.GitClient) if err != nil { return err } @@ -451,7 +455,7 @@ func createFromLocal(opts *CreateOptions) error { return fmt.Errorf("%s is not a git repository. Run `git -C \"%s\" init` to initialize it", absPath, repoPath) } - committed, err := hasCommits(repoPath) + committed, err := hasCommits(opts.GitClient) if err != nil { return err } @@ -533,7 +537,7 @@ func createFromLocal(opts *CreateOptions) error { } } - if err := sourceInit(opts.IO, remoteURL, baseRemote, repoPath); err != nil { + if err := sourceInit(opts.GitClient, opts.IO, remoteURL, baseRemote); err != nil { return err } @@ -547,7 +551,7 @@ func createFromLocal(opts *CreateOptions) error { } if opts.Push { - repoPush, err := git.GitCommand("-C", repoPath, "push", "-u", baseRemote, "HEAD") + repoPush, err := opts.GitClient.Command(context.Background(), "push", "-u", baseRemote, "HEAD") if err != nil { return err } @@ -563,17 +567,17 @@ func createFromLocal(opts *CreateOptions) error { return nil } -func sourceInit(io *iostreams.IOStreams, remoteURL, baseRemote, repoPath string) error { +func sourceInit(gitClient *git.Client, io *iostreams.IOStreams, remoteURL, baseRemote string) error { cs := io.ColorScheme() isTTY := io.IsStdoutTTY() stdout := io.Out - remoteAdd, err := git.GitCommand("-C", repoPath, "remote", "add", baseRemote, remoteURL) + remoteAdd, err := gitClient.Command(context.Background(), "remote", "add", baseRemote, remoteURL) if err != nil { return err } - err = remoteAdd.Run() + _, err = remoteAdd.Output() if err != nil { return fmt.Errorf("%s Unable to add remote %q", cs.FailureIcon(), baseRemote) } @@ -584,12 +588,12 @@ func sourceInit(io *iostreams.IOStreams, remoteURL, baseRemote, repoPath string) } // check if local repository has committed changes -func hasCommits(repoPath string) (bool, error) { - hasCommitsCmd, err := git.GitCommand("-C", repoPath, "rev-parse", "HEAD") +func hasCommits(gitClient *git.Client) (bool, error) { + hasCommitsCmd, err := gitClient.Command(context.Background(), "rev-parse", "HEAD") if err != nil { return false, err } - err = hasCommitsCmd.Run() + _, err = hasCommitsCmd.Output() if err == nil { return true, nil } @@ -606,8 +610,8 @@ func hasCommits(repoPath string) (bool, error) { } // check if path is the top level directory of a git repo -func isLocalRepo(repoPath string) (bool, error) { - projectDir, projectDirErr := git.GetDirFromPath(repoPath) +func isLocalRepo(gitClient *git.Client) (bool, error) { + projectDir, projectDirErr := gitClient.GitDir(context.Background()) if projectDirErr != nil { var execError *exec.ExitError if errors.As(projectDirErr, &execError) { @@ -624,28 +628,26 @@ func isLocalRepo(repoPath string) (bool, error) { } // clone the checkout branch to specified path -func localInit(io *iostreams.IOStreams, remoteURL, path, checkoutBranch string) error { - gitInit, err := git.GitCommand("init", path) +func localInit(gitClient *git.Client, remoteURL, path, checkoutBranch string) error { + ctx := context.Background() + gitInit, err := gitClient.Command(ctx, "init", path) if err != nil { return err } - isTTY := io.IsStdoutTTY() - if isTTY { - gitInit.Stdout = io.Out - } - gitInit.Stderr = io.ErrOut - err = gitInit.Run() + _, err = gitInit.Output() if err != nil { return err } - gitRemoteAdd, err := git.GitCommand("-C", path, "remote", "add", "origin", remoteURL) + // Clone the client so we do not modify the original client's RepoDir. + gc := cloneGitClient(gitClient) + gc.RepoDir = path + + gitRemoteAdd, err := gc.Command(ctx, "remote", "add", "origin", remoteURL) if err != nil { return err } - gitRemoteAdd.Stdout = io.Out - gitRemoteAdd.Stderr = io.ErrOut - err = gitRemoteAdd.Run() + _, err = gitRemoteAdd.Output() if err != nil { return err } @@ -654,24 +656,21 @@ func localInit(io *iostreams.IOStreams, remoteURL, path, checkoutBranch string) return nil } - gitFetch, err := git.GitCommand("-C", path, "fetch", "origin", fmt.Sprintf("+refs/heads/%[1]s:refs/remotes/origin/%[1]s", checkoutBranch)) + gitFetch, err := gc.Command(ctx, "fetch", "origin", fmt.Sprintf("+refs/heads/%[1]s:refs/remotes/origin/%[1]s", checkoutBranch)) if err != nil { return err } - gitFetch.Stdout = io.Out - gitFetch.Stderr = io.ErrOut err = gitFetch.Run() if err != nil { return err } - gitCheckout, err := git.GitCommand("-C", path, "checkout", checkoutBranch) + gitCheckout, err := gc.Command(ctx, "checkout", checkoutBranch) if err != nil { return err } - gitCheckout.Stdout = io.Out - gitCheckout.Stderr = io.ErrOut - return gitCheckout.Run() + _, err = gitCheckout.Output() + return err } func interactiveGitIgnore(client *http.Client, hostname string, prompter iprompter) (string, error) { @@ -736,3 +735,14 @@ func interactiveRepoInfo(prompter iprompter, defaultName string) (string, string return name, description, strings.ToUpper(visibilityOptions[selected]), nil } + +func cloneGitClient(c *git.Client) *git.Client { + return &git.Client{ + GhPath: c.GhPath, + RepoDir: c.RepoDir, + GitPath: c.GitPath, + Stderr: c.Stderr, + Stdin: c.Stdin, + Stdout: c.Stdout, + } +} diff --git a/pkg/cmd/repo/create/create_test.go b/pkg/cmd/repo/create/create_test.go index f8849afd1..a1ce875f0 100644 --- a/pkg/cmd/repo/create/create_test.go +++ b/pkg/cmd/repo/create/create_test.go @@ -6,6 +6,7 @@ import ( "net/http" "testing" + "github.com/cli/cli/v2/git" "github.com/cli/cli/v2/internal/config" "github.com/cli/cli/v2/internal/prompter" "github.com/cli/cli/v2/internal/run" @@ -492,6 +493,8 @@ func Test_createRun(t *testing.T) { return config.NewBlankConfig(), nil } + tt.opts.GitClient = &git.Client{GitPath: "some/path/git"} + ios, _, stdout, stderr := iostreams.Test() ios.SetStdinTTY(tt.tty) ios.SetStdoutTTY(tt.tty) diff --git a/pkg/cmd/repo/fork/fork.go b/pkg/cmd/repo/fork/fork.go index 9416ba4b2..e17e4b34d 100644 --- a/pkg/cmd/repo/fork/fork.go +++ b/pkg/cmd/repo/fork/fork.go @@ -1,6 +1,7 @@ package fork import ( + "context" "fmt" "net/http" "net/url" @@ -9,7 +10,7 @@ import ( "github.com/MakeNowJust/heredoc" "github.com/cli/cli/v2/api" - "github.com/cli/cli/v2/context" + ghContext "github.com/cli/cli/v2/context" "github.com/cli/cli/v2/git" "github.com/cli/cli/v2/internal/config" "github.com/cli/cli/v2/internal/ghrepo" @@ -25,10 +26,11 @@ const defaultRemoteName = "origin" type ForkOptions struct { HttpClient func() (*http.Client, error) + GitClient *git.Client Config func() (config.Config, error) IO *iostreams.IOStreams BaseRepo func() (ghrepo.Interface, error) - Remotes func() (context.Remotes, error) + Remotes func() (ghContext.Remotes, error) Since func(time.Time) time.Duration GitArgs []string @@ -51,6 +53,7 @@ func NewCmdFork(f *cmdutil.Factory, runF func(*ForkOptions) error) *cobra.Comman opts := &ForkOptions{ IO: f.IOStreams, HttpClient: f.HttpClient, + GitClient: f.GitClient, Config: f.Config, BaseRepo: f.BaseRepo, Remotes: f.Remotes, @@ -226,6 +229,9 @@ func forkRun(opts *ForkOptions) error { } protocol, _ := cfg.Get(repoToFork.RepoHost(), "git_protocol") + gitClient := opts.GitClient + ctx := context.Background() + if inParent { remotes, err := opts.Remotes() if err != nil { @@ -264,6 +270,7 @@ func forkRun(opts *ForkOptions) error { return fmt.Errorf("failed to prompt: %w", err) } } + if remoteDesired { remoteName := opts.RemoteName remotes, err := opts.Remotes() @@ -274,11 +281,11 @@ func forkRun(opts *ForkOptions) error { if _, err := remotes.FindByName(remoteName); err == nil { if opts.Rename { renameTarget := "upstream" - renameCmd, err := git.GitCommand("remote", "rename", remoteName, renameTarget) + renameCmd, err := gitClient.Command(ctx, "remote", "rename", remoteName, renameTarget) if err != nil { return err } - err = renameCmd.Run() + _, err = renameCmd.Output() if err != nil { return err } @@ -289,7 +296,7 @@ func forkRun(opts *ForkOptions) error { forkedRepoCloneURL := ghrepo.FormatRemoteURL(forkedRepo, protocol) - _, err = git.AddRemote(remoteName, forkedRepoCloneURL) + _, err = gitClient.AddRemote(ctx, remoteName, forkedRepoCloneURL, []string{}) if err != nil { return fmt.Errorf("failed to add remote: %w", err) } @@ -309,13 +316,13 @@ func forkRun(opts *ForkOptions) error { } if cloneDesired { forkedRepoURL := ghrepo.FormatRemoteURL(forkedRepo, protocol) - cloneDir, err := git.RunClone(forkedRepoURL, opts.GitArgs) + cloneDir, err := gitClient.Clone(ctx, forkedRepoURL, opts.GitArgs) if err != nil { return fmt.Errorf("failed to clone fork: %w", err) } upstreamURL := ghrepo.FormatRemoteURL(repoToFork, protocol) - err = git.AddNamedRemote(upstreamURL, "upstream", cloneDir, []string{}) + _, err = gitClient.AddRemote(ctx, "upstream", upstreamURL, []string{}, git.WithRepoDir(cloneDir)) if err != nil { return err } diff --git a/pkg/cmd/repo/fork/fork_test.go b/pkg/cmd/repo/fork/fork_test.go index 111eb98f6..e5ffda36b 100644 --- a/pkg/cmd/repo/fork/fork_test.go +++ b/pkg/cmd/repo/fork/fork_test.go @@ -700,6 +700,8 @@ func TestRepoFork(t *testing.T) { return tt.remotes, nil } + tt.opts.GitClient = &git.Client{GitPath: "some/path/git"} + //nolint:staticcheck // SA1019: prompt.InitAskStubber is deprecated: use NewAskStubber as, teardown := prompt.InitAskStubber() defer teardown() diff --git a/pkg/cmd/repo/rename/rename.go b/pkg/cmd/repo/rename/rename.go index 0b256d1d3..2d07cd8b0 100644 --- a/pkg/cmd/repo/rename/rename.go +++ b/pkg/cmd/repo/rename/rename.go @@ -1,13 +1,14 @@ package rename import ( + "context" "fmt" "net/http" "github.com/AlecAivazis/survey/v2" "github.com/MakeNowJust/heredoc" "github.com/cli/cli/v2/api" - "github.com/cli/cli/v2/context" + ghContext "github.com/cli/cli/v2/context" "github.com/cli/cli/v2/git" "github.com/cli/cli/v2/internal/config" "github.com/cli/cli/v2/internal/ghrepo" @@ -19,10 +20,11 @@ import ( type RenameOptions struct { HttpClient func() (*http.Client, error) + GitClient *git.Client IO *iostreams.IOStreams Config func() (config.Config, error) BaseRepo func() (ghrepo.Interface, error) - Remotes func() (context.Remotes, error) + Remotes func() (ghContext.Remotes, error) DoConfirm bool HasRepoOverride bool newRepoSelector string @@ -32,6 +34,7 @@ func NewCmdRename(f *cmdutil.Factory, runf func(*RenameOptions) error) *cobra.Co opts := &RenameOptions{ IO: f.IOStreams, HttpClient: f.HttpClient, + GitClient: f.GitClient, Remotes: f.Remotes, Config: f.Config, } @@ -145,7 +148,7 @@ func renameRun(opts *RenameOptions) error { return nil } -func updateRemote(repo ghrepo.Interface, renamed ghrepo.Interface, opts *RenameOptions) (*context.Remote, error) { +func updateRemote(repo ghrepo.Interface, renamed ghrepo.Interface, opts *RenameOptions) (*ghContext.Remote, error) { cfg, err := opts.Config() if err != nil { return nil, err @@ -167,6 +170,7 @@ func updateRemote(repo ghrepo.Interface, renamed ghrepo.Interface, opts *RenameO } remoteURL := ghrepo.FormatRemoteURL(renamed, protocol) - err = git.UpdateRemoteURL(remote.Name, remoteURL) + err = opts.GitClient.UpdateRemoteURL(context.Background(), remote.Name, remoteURL) + return remote, err } diff --git a/pkg/cmd/repo/rename/rename_test.go b/pkg/cmd/repo/rename/rename_test.go index 97e1aebb7..523b2ba4a 100644 --- a/pkg/cmd/repo/rename/rename_test.go +++ b/pkg/cmd/repo/rename/rename_test.go @@ -259,6 +259,8 @@ func TestRenameRun(t *testing.T) { ios.SetStdoutTTY(tt.tty) tt.opts.IO = ios + tt.opts.GitClient = &git.Client{GitPath: "some/path/git"} + t.Run(tt.name, func(t *testing.T) { defer reg.Verify(t) err := renameRun(&tt.opts) diff --git a/pkg/cmd/repo/sync/git.go b/pkg/cmd/repo/sync/git.go index 1ec86b053..fbb63a2ec 100644 --- a/pkg/cmd/repo/sync/git.go +++ b/pkg/cmd/repo/sync/git.go @@ -1,11 +1,11 @@ package sync import ( + "context" "fmt" "strings" "github.com/cli/cli/v2/git" - "github.com/cli/cli/v2/pkg/iostreams" ) type gitClient interface { @@ -22,12 +22,12 @@ type gitClient interface { } type gitExecuter struct { - io *iostreams.IOStreams + client *git.Client } func (g *gitExecuter) BranchRemote(branch string) (string, error) { args := []string{"rev-parse", "--symbolic-full-name", "--abbrev-ref", fmt.Sprintf("%s@{u}", branch)} - cmd, err := git.GitCommand(args...) + cmd, err := g.client.Command(context.Background(), args...) if err != nil { return "", err } @@ -40,60 +40,60 @@ func (g *gitExecuter) BranchRemote(branch string) (string, error) { } func (g *gitExecuter) UpdateBranch(branch, ref string) error { - cmd, err := git.GitCommand("update-ref", fmt.Sprintf("refs/heads/%s", branch), ref) + cmd, err := g.client.Command(context.Background(), "update-ref", fmt.Sprintf("refs/heads/%s", branch), ref) if err != nil { return err } - return cmd.Run() + _, err = cmd.Output() + return err } func (g *gitExecuter) CreateBranch(branch, ref, upstream string) error { - cmd, err := git.GitCommand("branch", branch, ref) + ctx := context.Background() + cmd, err := g.client.Command(ctx, "branch", branch, ref) if err != nil { return err } - if err := cmd.Run(); err != nil { + if _, err := cmd.Output(); err != nil { return err } - cmd, err = git.GitCommand("branch", "--set-upstream-to", upstream, branch) + cmd, err = g.client.Command(ctx, "branch", "--set-upstream-to", upstream, branch) if err != nil { return err } - return cmd.Run() + _, err = cmd.Output() + return err } func (g *gitExecuter) CurrentBranch() (string, error) { - return git.CurrentBranch() + return g.client.CurrentBranch(context.Background()) } func (g *gitExecuter) Fetch(remote, ref string) error { args := []string{"fetch", "-q", remote, ref} - cmd, err := git.GitCommand(args...) + cmd, err := g.client.Command(context.Background(), args...) if err != nil { return err } - cmd.Stdin = g.io.In - cmd.Stdout = g.io.Out - cmd.Stderr = g.io.ErrOut return cmd.Run() } func (g *gitExecuter) HasLocalBranch(branch string) bool { - return git.HasLocalBranch(branch) + return g.client.HasLocalBranch(context.Background(), branch) } func (g *gitExecuter) IsAncestor(ancestor, progeny string) (bool, error) { args := []string{"merge-base", "--is-ancestor", ancestor, progeny} - cmd, err := git.GitCommand(args...) + cmd, err := g.client.Command(context.Background(), args...) if err != nil { return false, err } - err = cmd.Run() + _, err = cmd.Output() return err == nil, nil } func (g *gitExecuter) IsDirty() (bool, error) { - cmd, err := git.GitCommand("status", "--untracked-files=no", "--porcelain") + cmd, err := g.client.Command(context.Background(), "status", "--untracked-files=no", "--porcelain") if err != nil { return false, err } @@ -109,18 +109,20 @@ func (g *gitExecuter) IsDirty() (bool, error) { func (g *gitExecuter) MergeFastForward(ref string) error { args := []string{"merge", "--ff-only", "--quiet", ref} - cmd, err := git.GitCommand(args...) + cmd, err := g.client.Command(context.Background(), args...) if err != nil { return err } - return cmd.Run() + _, err = cmd.Output() + return err } func (g *gitExecuter) ResetHard(ref string) error { args := []string{"reset", "--hard", ref} - cmd, err := git.GitCommand(args...) + cmd, err := g.client.Command(context.Background(), args...) if err != nil { return err } - return cmd.Run() + _, err = cmd.Output() + return err } diff --git a/pkg/cmd/repo/sync/sync.go b/pkg/cmd/repo/sync/sync.go index ba2fdbafb..eebec4389 100644 --- a/pkg/cmd/repo/sync/sync.go +++ b/pkg/cmd/repo/sync/sync.go @@ -39,7 +39,7 @@ func NewCmdSync(f *cmdutil.Factory, runF func(*SyncOptions) error) *cobra.Comman IO: f.IOStreams, BaseRepo: f.BaseRepo, Remotes: f.Remotes, - Git: &gitExecuter{io: f.IOStreams}, + Git: &gitExecuter{client: f.GitClient}, } cmd := &cobra.Command{ From 2cefb9fa591893cd677a88ef81186329c21272f2 Mon Sep 17 00:00:00 2001 From: Sam Coe Date: Thu, 20 Oct 2022 12:46:23 +0300 Subject: [PATCH 42/44] Fix pr create regression (#6472) * Revert "Refactor to use new git client (#6447)" This reverts commit 57fbe4f317ca7d0849eeeedb16c1abc21a81913b. * Fix pr create regression --- context/context.go | 4 +- git/client.go | 190 ++++++--------------- git/client_test.go | 2 +- git/git.go | 154 +++++++++++++++++ internal/run/run.go | 4 - pkg/cmd/auth/login/login.go | 4 - pkg/cmd/auth/login/login_test.go | 3 - pkg/cmd/auth/refresh/refresh.go | 10 +- pkg/cmd/auth/refresh/refresh_test.go | 2 +- pkg/cmd/auth/setupgit/setupgit.go | 1 - pkg/cmd/auth/shared/git_credential.go | 30 ++-- pkg/cmd/auth/shared/git_credential_test.go | 4 - pkg/cmd/auth/shared/login_flow.go | 8 +- pkg/cmd/browse/browse.go | 21 +-- pkg/cmd/browse/browse_test.go | 2 +- pkg/cmd/extension/manager.go | 4 +- pkg/cmd/factory/default.go | 23 ++- pkg/cmd/gist/clone/clone.go | 5 +- pkg/cmd/gist/clone/clone_test.go | 2 - pkg/cmd/pr/checkout/checkout.go | 65 +++---- pkg/cmd/pr/checkout/checkout_test.go | 3 - pkg/cmd/pr/close/close.go | 10 +- pkg/cmd/pr/close/close_test.go | 2 - pkg/cmd/pr/create/create.go | 49 +++--- pkg/cmd/pr/create/create_test.go | 4 +- pkg/cmd/pr/merge/merge.go | 25 ++- pkg/cmd/pr/merge/merge_test.go | 1 - pkg/cmd/pr/shared/finder.go | 14 +- pkg/cmd/pr/shared/survey.go | 17 ++ pkg/cmd/pr/shared/templates.go | 4 +- pkg/cmd/pr/status/status.go | 13 +- pkg/cmd/pr/status/status_test.go | 4 +- pkg/cmd/release/create/create.go | 23 ++- pkg/cmd/release/create/create_test.go | 5 - pkg/cmd/repo/clone/clone.go | 9 +- pkg/cmd/repo/clone/clone_test.go | 2 - pkg/cmd/repo/create/create.go | 76 ++++----- pkg/cmd/repo/create/create_test.go | 3 - pkg/cmd/repo/fork/fork.go | 21 +-- pkg/cmd/repo/fork/fork_test.go | 2 - pkg/cmd/repo/rename/rename.go | 12 +- pkg/cmd/repo/rename/rename_test.go | 2 - pkg/cmd/repo/sync/git.go | 88 +++++----- pkg/cmd/repo/sync/sync.go | 2 +- 44 files changed, 446 insertions(+), 483 deletions(-) create mode 100644 git/git.go diff --git a/context/context.go b/context/context.go index 568e3e623..45dfcfd97 100644 --- a/context/context.go +++ b/context/context.go @@ -2,7 +2,6 @@ package context import ( - "context" "errors" "sort" @@ -139,8 +138,7 @@ func (r *ResolvedRemotes) BaseRepo(io *iostreams.IOStreams, p iprompter) (ghrepo } // cache the result to git config - c := &git.Client{} - err := c.SetRemoteResolution(context.Background(), remote.Name, resolution) + err := git.SetRemoteResolution(remote.Name, resolution) return selectedRepo, err } diff --git a/git/client.go b/git/client.go index b91d0aacb..bada86265 100644 --- a/git/client.go +++ b/git/client.go @@ -38,16 +38,22 @@ func (e *NotInstalled) Unwrap() error { } type GitError struct { - ExitCode int - Stderr string - err error + stderr string + err error } func (ge *GitError) Error() string { - if ge.Stderr == "" { + stderr := ge.stderr + if stderr == "" { + var exitError *exec.ExitError + if errors.As(ge.err, &exitError) { + stderr = string(exitError.Stderr) + } + } + if stderr == "" { return fmt.Sprintf("failed to run git: %v", ge.err) } - return fmt.Sprintf("failed to run git: %s", ge.Stderr) + return fmt.Sprintf("failed to run git: %s", stderr) } func (ge *GitError) Unwrap() error { @@ -58,77 +64,16 @@ type gitCommand struct { *exec.Cmd } +// This is a hack in order to not break the hundreds of +// existing tests that rely on `run.PrepareCmd` to be invoked. func (gc *gitCommand) Run() error { - // This is a hack in order to not break the hundreds of - // existing tests that rely on `run.PrepareCmd` to be invoked. - err := run.PrepareCmd(gc.Cmd).Run() - if err != nil { - ge := GitError{err: err} - var exitError *exec.ExitError - if errors.As(err, &exitError) { - ge.Stderr = string(exitError.Stderr) - ge.ExitCode = exitError.ExitCode() - } - return &ge - } - return nil + return run.PrepareCmd(gc.Cmd).Run() } +// This is a hack in order to not break the hundreds of +// existing tests that rely on `run.PrepareCmd` to be invoked. func (gc *gitCommand) Output() ([]byte, error) { - gc.Stdout = nil - gc.Stderr = nil - // This is a hack in order to not break the hundreds of - // existing tests that rely on `run.PrepareCmd` to be invoked. - out, err := run.PrepareCmd(gc.Cmd).Output() - if err != nil { - ge := GitError{err: err} - var exitError *exec.ExitError - if errors.As(err, &exitError) { - ge.Stderr = string(exitError.Stderr) - ge.ExitCode = exitError.ExitCode() - } - return []byte{}, &ge - } - return out, nil -} - -func (gc *gitCommand) setRepoDir(repoDir string) { - for i, arg := range gc.Args { - if arg == "-C" { - gc.Args[i+1] = repoDir - return - } - } - gc.Args = append(gc.Args[:3], gc.Args[1:]...) - gc.Args[1] = "-C" - gc.Args[2] = repoDir -} - -// Allow individual commands to be modified from the default client options. -type CommandModifier func(*gitCommand) - -func WithStderr(stderr io.Writer) CommandModifier { - return func(gc *gitCommand) { - gc.Stderr = stderr - } -} - -func WithStdout(stdout io.Writer) CommandModifier { - return func(gc *gitCommand) { - gc.Stdout = stdout - } -} - -func WithStdin(stdin io.Reader) CommandModifier { - return func(gc *gitCommand) { - gc.Stdin = stdin - } -} - -func WithRepoDir(repoDir string) CommandModifier { - return func(gc *gitCommand) { - gc.setRepoDir(repoDir) - } + return run.PrepareCmd(gc.Cmd).Output() } type Client struct { @@ -188,7 +133,8 @@ func resolveGitPath() (string, error) { // AuthenticatedCommand is a wrapper around Command that included configuration to use gh // as the credential helper for git. func (c *Client) AuthenticatedCommand(ctx context.Context, args ...string) (*gitCommand, error) { - preArgs := []string{"-c", "credential.helper="} + preArgs := []string{} + preArgs = append(preArgs, "-c", "credential.helper=") if c.GhPath == "" { // Assumes that gh is in PATH. c.GhPath = "gh" @@ -207,7 +153,7 @@ func (c *Client) Remotes(ctx context.Context) (RemoteSet, error) { } remoteOut, remoteErr := remoteCmd.Output() if remoteErr != nil { - return nil, remoteErr + return nil, &GitError{err: remoteErr} } configArgs := []string{"config", "--get-regexp", `^remote\..*\.gh-resolved$`} @@ -218,9 +164,9 @@ func (c *Client) Remotes(ctx context.Context) (RemoteSet, error) { configOut, configErr := configCmd.Output() if configErr != nil { // Ignore exit code 1 as it means there are no resolved remotes. - var gitErr *GitError - if ok := errors.As(configErr, &gitErr); ok && gitErr.ExitCode != 1 { - return nil, gitErr + var exitErr *exec.ExitError + if errors.As(configErr, &exitErr) && exitErr.ExitCode() != 1 { + return nil, &GitError{err: configErr} } } @@ -230,20 +176,18 @@ func (c *Client) Remotes(ctx context.Context) (RemoteSet, error) { return remotes, nil } -func (c *Client) AddRemote(ctx context.Context, name, urlStr string, trackingBranches []string, mods ...CommandModifier) (*Remote, error) { +func (c *Client) AddRemote(ctx context.Context, name, urlStr string, trackingBranches []string) (*Remote, error) { args := []string{"remote", "add"} for _, branch := range trackingBranches { args = append(args, "-t", branch) } args = append(args, "-f", name, urlStr) + //TODO: Use AuthenticatedCommand cmd, err := c.Command(ctx, args...) if err != nil { return nil, err } - for _, mod := range mods { - mod(cmd) - } - if _, err := cmd.Output(); err != nil { + if err := cmd.Run(); err != nil { return nil, err } var urlParsed *url.URL @@ -272,11 +216,7 @@ func (c *Client) UpdateRemoteURL(ctx context.Context, name, url string) error { if err != nil { return err } - _, err = cmd.Output() - if err != nil { - return err - } - return nil + return cmd.Run() } func (c *Client) SetRemoteResolution(ctx context.Context, name, resolution string) error { @@ -285,11 +225,7 @@ func (c *Client) SetRemoteResolution(ctx context.Context, name, resolution strin if err != nil { return err } - _, err = cmd.Output() - if err != nil { - return err - } - return nil + return cmd.Run() } // CurrentBranch reads the checked-out branch for the git repository. @@ -299,14 +235,14 @@ func (c *Client) CurrentBranch(ctx context.Context) (string, error) { if err != nil { return "", err } + errBuf := bytes.Buffer{} + cmd.Stderr = &errBuf out, err := cmd.Output() if err != nil { - var gitErr *GitError - if ok := errors.As(err, &gitErr); ok && len(gitErr.Stderr) == 0 { - gitErr.Stderr = "not on any branch" - return "", gitErr + if errBuf.Len() == 0 { + return "", &GitError{err: err, stderr: "not on any branch"} } - return "", err + return "", &GitError{err: err, stderr: errBuf.String()} } branch := firstLine(out) return strings.TrimPrefix(branch, "refs/heads/"), nil @@ -319,10 +255,9 @@ func (c *Client) ShowRefs(ctx context.Context, ref ...string) ([]Ref, error) { if err != nil { return nil, err } + // This functionality relies on parsing output from the git command despite + // an error status being returned from git. out, err := cmd.Output() - if err != nil { - return nil, err - } var refs []Ref for _, line := range outputLines(out) { parts := strings.SplitN(line, " ", 2) @@ -334,7 +269,7 @@ func (c *Client) ShowRefs(ctx context.Context, ref ...string) ([]Ref, error) { Name: parts[1], }) } - return refs, nil + return refs, err } func (c *Client) Config(ctx context.Context, name string) (string, error) { @@ -343,14 +278,15 @@ func (c *Client) Config(ctx context.Context, name string) (string, error) { if err != nil { return "", err } + errBuf := bytes.Buffer{} + cmd.Stderr = &errBuf out, err := cmd.Output() if err != nil { - var gitErr *GitError - if ok := errors.As(err, &gitErr); ok && gitErr.ExitCode == 1 { - gitErr.Stderr = fmt.Sprintf("unknown config key %s", name) - return "", gitErr + var exitError *exec.ExitError + if ok := errors.As(err, &exitError); ok && exitError.Error() == "1" { + return "", &GitError{err: err, stderr: fmt.Sprintf("unknown config key %s", name)} } - return "", err + return "", &GitError{err: err, stderr: errBuf.String()} } return firstLine(out), nil } @@ -363,7 +299,7 @@ func (c *Client) UncommittedChangeCount(ctx context.Context) (int, error) { } out, err := cmd.Output() if err != nil { - return 0, err + return 0, &GitError{err: err} } lines := strings.Split(string(out), "\n") count := 0 @@ -383,7 +319,7 @@ func (c *Client) Commits(ctx context.Context, baseRef, headRef string) ([]*Commi } out, err := cmd.Output() if err != nil { - return nil, err + return nil, &GitError{err: err} } commits := []*Commit{} sha := 0 @@ -412,7 +348,7 @@ func (c *Client) lookupCommit(ctx context.Context, sha, format string) ([]byte, } out, err := cmd.Output() if err != nil { - return nil, err + return nil, &GitError{err: err} } return out, nil } @@ -435,16 +371,13 @@ func (c *Client) CommitBody(ctx context.Context, sha string) (string, error) { } // Push publishes a git ref to a remote and sets up upstream configuration. -func (c *Client) Push(ctx context.Context, remote string, ref string, mods ...CommandModifier) error { +func (c *Client) Push(ctx context.Context, remote string, ref string) error { args := []string{"push", "--set-upstream", remote, ref} //TODO: Use AuthenticatedCommand cmd, err := c.Command(ctx, args...) if err != nil { return err } - for _, mod := range mods { - mod(cmd) - } return cmd.Run() } @@ -490,11 +423,7 @@ func (c *Client) DeleteLocalBranch(ctx context.Context, branch string) error { if err != nil { return err } - _, err = cmd.Output() - if err != nil { - return err - } - return nil + return cmd.Run() } func (c *Client) HasLocalBranch(ctx context.Context, branch string) bool { @@ -503,7 +432,7 @@ func (c *Client) HasLocalBranch(ctx context.Context, branch string) bool { if err != nil { return false } - _, err = cmd.Output() + err = cmd.Run() return err == nil } @@ -513,11 +442,7 @@ func (c *Client) CheckoutBranch(ctx context.Context, branch string) error { if err != nil { return err } - _, err = cmd.Output() - if err != nil { - return err - } - return nil + return cmd.Run() } func (c *Client) CheckoutNewBranch(ctx context.Context, remoteName, branch string) error { @@ -527,11 +452,7 @@ func (c *Client) CheckoutNewBranch(ctx context.Context, remoteName, branch strin if err != nil { return err } - _, err = cmd.Output() - if err != nil { - return err - } - return nil + return cmd.Run() } func (c *Client) Pull(ctx context.Context, remote, branch string) error { @@ -544,7 +465,7 @@ func (c *Client) Pull(ctx context.Context, remote, branch string) error { return cmd.Run() } -func (c *Client) Clone(ctx context.Context, cloneURL string, args []string) (string, error) { +func (c *Client) Clone(ctx context.Context, cloneURL string, args []string) (target string, err error) { cloneArgs, target := parseCloneArgs(args) cloneArgs = append(cloneArgs, cloneURL) // If the args contain an explicit target, pass it to clone @@ -561,10 +482,7 @@ func (c *Client) Clone(ctx context.Context, cloneURL string, args []string) (str return "", err } err = cmd.Run() - if err != nil { - return "", err - } - return target, nil + return } // ToplevelDir returns the top-level directory path of the current repository. @@ -576,7 +494,7 @@ func (c *Client) ToplevelDir(ctx context.Context) (string, error) { } out, err := cmd.Output() if err != nil { - return "", err + return "", &GitError{err: err} } return firstLine(out), nil } @@ -589,7 +507,7 @@ func (c *Client) GitDir(ctx context.Context) (string, error) { } out, err := cmd.Output() if err != nil { - return "", err + return "", &GitError{err: err} } return firstLine(out), nil } diff --git a/git/client_test.go b/git/client_test.go index cff47358f..121231d23 100644 --- a/git/client_test.go +++ b/git/client_test.go @@ -394,6 +394,6 @@ func initRepo(t *testing.T, dir string) { } cmd, err := client.Command(context.Background(), []string{"init", "--quiet"}...) assert.NoError(t, err) - _, err = cmd.Output() + err = cmd.Run() assert.NoError(t, err) } diff --git a/git/git.go b/git/git.go new file mode 100644 index 000000000..5b934175b --- /dev/null +++ b/git/git.go @@ -0,0 +1,154 @@ +package git + +import ( + "context" + "io" + "os" +) + +func GitCommand(args ...string) (*gitCommand, error) { + c := &Client{} + return c.Command(context.Background(), args...) +} + +func ShowRefs(ref ...string) ([]Ref, error) { + c := &Client{} + return c.ShowRefs(context.Background(), ref...) +} + +func CurrentBranch() (string, error) { + c := &Client{} + return c.CurrentBranch(context.Background()) +} + +func Config(name string) (string, error) { + c := &Client{} + return c.Config(context.Background(), name) +} + +func UncommittedChangeCount() (int, error) { + c := &Client{} + return c.UncommittedChangeCount(context.Background()) +} + +func Commits(baseRef, headRef string) ([]*Commit, error) { + c := &Client{} + return c.Commits(context.Background(), baseRef, headRef) +} + +func LastCommit() (*Commit, error) { + c := &Client{} + return c.LastCommit(context.Background()) +} + +func CommitBody(sha string) (string, error) { + c := &Client{} + return c.CommitBody(context.Background(), sha) +} + +func Push(remote string, ref string, cmdIn io.ReadCloser, cmdOut, cmdErr io.Writer) error { + //TODO: Replace with factory GitClient and use AuthenticatedCommand + c := &Client{ + Stdin: cmdIn, + Stdout: cmdOut, + Stderr: cmdErr, + } + return c.Push(context.Background(), remote, ref) +} + +func ReadBranchConfig(branch string) (cfg BranchConfig) { + c := &Client{} + return c.ReadBranchConfig(context.Background(), branch) +} + +func DeleteLocalBranch(branch string) error { + c := &Client{} + return c.DeleteLocalBranch(context.Background(), branch) +} + +func HasLocalBranch(branch string) bool { + c := &Client{} + return c.HasLocalBranch(context.Background(), branch) +} + +func CheckoutBranch(branch string) error { + c := &Client{} + return c.CheckoutBranch(context.Background(), branch) +} + +func CheckoutNewBranch(remoteName, branch string) error { + c := &Client{} + return c.CheckoutNewBranch(context.Background(), remoteName, branch) +} + +func Pull(remote, branch string) error { + //TODO: Replace with factory GitClient and use AuthenticatedCommand + c := &Client{ + Stdin: os.Stdin, + Stdout: os.Stdout, + Stderr: os.Stderr, + } + return c.Pull(context.Background(), remote, branch) +} + +func RunClone(cloneURL string, args []string) (target string, err error) { + //TODO: Replace with factory GitClient and use AuthenticatedCommand + c := &Client{ + Stdin: os.Stdin, + Stdout: os.Stdout, + Stderr: os.Stderr, + } + return c.Clone(context.Background(), cloneURL, args) +} + +func ToplevelDir() (string, error) { + c := &Client{} + return c.ToplevelDir(context.Background()) +} + +func GetDirFromPath(repoDir string) (string, error) { + c := &Client{ + RepoDir: repoDir, + } + return c.GitDir(context.Background()) +} + +func PathFromRepoRoot() string { + c := &Client{} + return c.PathFromRoot(context.Background()) +} + +func Remotes() (RemoteSet, error) { + c := &Client{} + return c.Remotes(context.Background()) +} + +func RemotesForPath(repoDir string) (RemoteSet, error) { + c := &Client{ + RepoDir: repoDir, + } + return c.Remotes(context.Background()) +} + +func AddRemote(name, url string) (*Remote, error) { + c := &Client{} + return c.AddRemote(context.Background(), name, url, []string{}) +} + +func AddNamedRemote(url, name, repoDir string, branches []string) error { + c := &Client{ + RepoDir: repoDir, + } + _, err := c.AddRemote(context.Background(), name, url, branches) + return err +} + +func UpdateRemoteURL(name, url string) error { + c := &Client{} + return c.UpdateRemoteURL(context.Background(), name, url) +} + +func SetRemoteResolution(name, resolution string) error { + c := &Client{} + return c.SetRemoteResolution(context.Background(), name, resolution) +} diff --git a/internal/run/run.go b/internal/run/run.go index a7260db14..d482e04cc 100644 --- a/internal/run/run.go +++ b/internal/run/run.go @@ -76,10 +76,6 @@ func (e CmdError) Error() string { return fmt.Sprintf("%s%s: %s", msg, e.Args[0], e.Err) } -func (e CmdError) Unwrap() error { - return e.Err -} - func printArgs(w io.Writer, args []string) error { if len(args) > 0 { // print commands, but omit the full path to an executable diff --git a/pkg/cmd/auth/login/login.go b/pkg/cmd/auth/login/login.go index 364abb9f0..be12392d1 100644 --- a/pkg/cmd/auth/login/login.go +++ b/pkg/cmd/auth/login/login.go @@ -7,7 +7,6 @@ import ( "strings" "github.com/MakeNowJust/heredoc" - "github.com/cli/cli/v2/git" "github.com/cli/cli/v2/internal/config" "github.com/cli/cli/v2/internal/ghinstance" "github.com/cli/cli/v2/pkg/cmd/auth/shared" @@ -21,7 +20,6 @@ type LoginOptions struct { IO *iostreams.IOStreams Config func() (config.Config, error) HttpClient func() (*http.Client, error) - GitClient *git.Client Prompter shared.Prompt MainExecutable string @@ -40,7 +38,6 @@ func NewCmdLogin(f *cmdutil.Factory, runF func(*LoginOptions) error) *cobra.Comm IO: f.IOStreams, Config: f.Config, HttpClient: f.HttpClient, - GitClient: f.GitClient, Prompter: f.Prompter, } @@ -186,7 +183,6 @@ func loginRun(opts *LoginOptions) error { Executable: opts.MainExecutable, GitProtocol: opts.GitProtocol, Prompter: opts.Prompter, - GitClient: opts.GitClient, }) } diff --git a/pkg/cmd/auth/login/login_test.go b/pkg/cmd/auth/login/login_test.go index 85e133b2b..649fdb48d 100644 --- a/pkg/cmd/auth/login/login_test.go +++ b/pkg/cmd/auth/login/login_test.go @@ -8,7 +8,6 @@ import ( "testing" "github.com/MakeNowJust/heredoc" - "github.com/cli/cli/v2/git" "github.com/cli/cli/v2/internal/config" "github.com/cli/cli/v2/internal/prompter" "github.com/cli/cli/v2/internal/run" @@ -598,8 +597,6 @@ func Test_loginRun_Survey(t *testing.T) { } tt.opts.Prompter = pm - tt.opts.GitClient = &git.Client{GitPath: "some/path/git"} - rs, restoreRun := run.Stub() defer restoreRun(t) if tt.runStubs != nil { diff --git a/pkg/cmd/auth/refresh/refresh.go b/pkg/cmd/auth/refresh/refresh.go index c66fb5237..7b1142e7e 100644 --- a/pkg/cmd/auth/refresh/refresh.go +++ b/pkg/cmd/auth/refresh/refresh.go @@ -6,7 +6,6 @@ import ( "strings" "github.com/MakeNowJust/heredoc" - "github.com/cli/cli/v2/git" "github.com/cli/cli/v2/internal/authflow" "github.com/cli/cli/v2/internal/config" "github.com/cli/cli/v2/pkg/cmd/auth/shared" @@ -18,8 +17,7 @@ import ( type RefreshOptions struct { IO *iostreams.IOStreams Config func() (config.Config, error) - HttpClient *http.Client - GitClient *git.Client + httpClient *http.Client Prompter shared.Prompt MainExecutable string @@ -39,8 +37,7 @@ func NewCmdRefresh(f *cmdutil.Factory, runF func(*RefreshOptions) error) *cobra. _, err := authflow.AuthFlowWithConfig(cfg, io, hostname, "", scopes, interactive) return err }, - HttpClient: &http.Client{}, - GitClient: f.GitClient, + httpClient: &http.Client{}, Prompter: f.Prompter, } @@ -125,7 +122,7 @@ func refreshRun(opts *RefreshOptions) error { var additionalScopes []string if oldToken, _ := cfg.AuthToken(hostname); oldToken != "" { - if oldScopes, err := shared.GetScopes(opts.HttpClient, hostname, oldToken); err == nil { + if oldScopes, err := shared.GetScopes(opts.httpClient, hostname, oldToken); err == nil { for _, s := range strings.Split(oldScopes, ",") { s = strings.TrimSpace(s) if s != "" { @@ -138,7 +135,6 @@ func refreshRun(opts *RefreshOptions) error { credentialFlow := &shared.GitCredentialFlow{ Executable: opts.MainExecutable, Prompter: opts.Prompter, - GitClient: opts.GitClient, } gitProtocol, _ := cfg.GetOrDefault(hostname, "git_protocol") if opts.Interactive && gitProtocol == "https" { diff --git a/pkg/cmd/auth/refresh/refresh_test.go b/pkg/cmd/auth/refresh/refresh_test.go index d1d6042c0..ffacb22d9 100644 --- a/pkg/cmd/auth/refresh/refresh_test.go +++ b/pkg/cmd/auth/refresh/refresh_test.go @@ -272,7 +272,7 @@ func Test_refreshRun(t *testing.T) { }, nil }, ) - tt.opts.HttpClient = &http.Client{Transport: httpReg} + tt.opts.httpClient = &http.Client{Transport: httpReg} pm := &prompter.PrompterMock{} if tt.prompterStubs != nil { diff --git a/pkg/cmd/auth/setupgit/setupgit.go b/pkg/cmd/auth/setupgit/setupgit.go index edc531235..b5caefe5b 100644 --- a/pkg/cmd/auth/setupgit/setupgit.go +++ b/pkg/cmd/auth/setupgit/setupgit.go @@ -34,7 +34,6 @@ func NewCmdSetupGit(f *cmdutil.Factory, runF func(*SetupGitOptions) error) *cobr RunE: func(cmd *cobra.Command, args []string) error { opts.gitConfigure = &shared.GitCredentialFlow{ Executable: f.Executable(), - GitClient: f.GitClient, } if runF != nil { diff --git a/pkg/cmd/auth/shared/git_credential.go b/pkg/cmd/auth/shared/git_credential.go index 5ddac14d5..491e2c82f 100644 --- a/pkg/cmd/auth/shared/git_credential.go +++ b/pkg/cmd/auth/shared/git_credential.go @@ -2,7 +2,6 @@ package shared import ( "bytes" - "context" "errors" "fmt" "path/filepath" @@ -17,7 +16,6 @@ import ( type GitCredentialFlow struct { Executable string Prompter Prompt - GitClient *git.Client shouldSetup bool helper string @@ -26,7 +24,7 @@ type GitCredentialFlow struct { func (flow *GitCredentialFlow) Prompt(hostname string) error { var gitErr error - flow.helper, gitErr = gitCredentialHelper(flow.GitClient, hostname) + flow.helper, gitErr = gitCredentialHelper(hostname) if isOurCredentialHelper(flow.helper) { flow.scopes = append(flow.scopes, "workflow") return nil @@ -61,9 +59,6 @@ func (flow *GitCredentialFlow) Setup(hostname, username, authToken string) error } func (flow *GitCredentialFlow) gitCredentialSetup(hostname, username, password string) error { - gitClient := flow.GitClient - ctx := context.Background() - if flow.helper == "" { credHelperKeys := []string{ gitCredentialHelperKey(hostname), @@ -81,18 +76,18 @@ func (flow *GitCredentialFlow) gitCredentialSetup(hostname, username, password s break } // first use a blank value to indicate to git we want to sever the chain of credential helpers - preConfigureCmd, err := gitClient.Command(ctx, "config", "--global", "--replace-all", credHelperKey, "") + preConfigureCmd, err := git.GitCommand("config", "--global", "--replace-all", credHelperKey, "") if err != nil { configErr = err break } - if _, err = preConfigureCmd.Output(); err != nil { + if err = preConfigureCmd.Run(); err != nil { configErr = err break } // second configure the actual helper for this host - configureCmd, err := gitClient.Command(ctx, + configureCmd, err := git.GitCommand( "config", "--global", "--add", credHelperKey, fmt.Sprintf("!%s auth git-credential", shellQuote(flow.Executable)), @@ -100,7 +95,7 @@ func (flow *GitCredentialFlow) gitCredentialSetup(hostname, username, password s if err != nil { configErr = err } else { - _, configErr = configureCmd.Output() + configErr = configureCmd.Run() } } @@ -108,7 +103,7 @@ func (flow *GitCredentialFlow) gitCredentialSetup(hostname, username, password s } // clear previous cached credentials - rejectCmd, err := gitClient.Command(ctx, "credential", "reject") + rejectCmd, err := git.GitCommand("credential", "reject") if err != nil { return err } @@ -118,12 +113,12 @@ func (flow *GitCredentialFlow) gitCredentialSetup(hostname, username, password s host=%s `, hostname)) - _, err = rejectCmd.Output() + err = rejectCmd.Run() if err != nil { return err } - approveCmd, err := gitClient.Command(ctx, "credential", "approve") + approveCmd, err := git.GitCommand("credential", "approve") if err != nil { return err } @@ -135,7 +130,7 @@ func (flow *GitCredentialFlow) gitCredentialSetup(hostname, username, password s password=%s `, hostname, username, password)) - _, err = approveCmd.Output() + err = approveCmd.Run() if err != nil { return err } @@ -148,13 +143,12 @@ func gitCredentialHelperKey(hostname string) string { return fmt.Sprintf("credential.%s.helper", host) } -func gitCredentialHelper(gitClient *git.Client, hostname string) (helper string, err error) { - ctx := context.Background() - helper, err = gitClient.Config(ctx, gitCredentialHelperKey(hostname)) +func gitCredentialHelper(hostname string) (helper string, err error) { + helper, err = git.Config(gitCredentialHelperKey(hostname)) if helper != "" { return } - helper, err = gitClient.Config(ctx, "credential.helper") + helper, err = git.Config("credential.helper") return } diff --git a/pkg/cmd/auth/shared/git_credential_test.go b/pkg/cmd/auth/shared/git_credential_test.go index 9d3e90cc4..fe674e1d7 100644 --- a/pkg/cmd/auth/shared/git_credential_test.go +++ b/pkg/cmd/auth/shared/git_credential_test.go @@ -3,7 +3,6 @@ package shared import ( "testing" - "github.com/cli/cli/v2/git" "github.com/cli/cli/v2/internal/run" ) @@ -16,7 +15,6 @@ func TestGitCredentialSetup_configureExisting(t *testing.T) { f := GitCredentialFlow{ Executable: "gh", helper: "osxkeychain", - GitClient: &git.Client{GitPath: "some/path/git"}, } if err := f.gitCredentialSetup("example.com", "monalisa", "PASSWD"); err != nil { @@ -63,7 +61,6 @@ func TestGitCredentialsSetup_setOurs_GH(t *testing.T) { f := GitCredentialFlow{ Executable: "/path/to/gh", helper: "", - GitClient: &git.Client{GitPath: "some/path/git"}, } if err := f.gitCredentialSetup("github.com", "monalisa", "PASSWD"); err != nil { @@ -95,7 +92,6 @@ func TestGitCredentialSetup_setOurs_nonGH(t *testing.T) { f := GitCredentialFlow{ Executable: "/path/to/gh", helper: "", - GitClient: &git.Client{GitPath: "some/path/git"}, } if err := f.gitCredentialSetup("example.com", "monalisa", "PASSWD"); err != nil { diff --git a/pkg/cmd/auth/shared/login_flow.go b/pkg/cmd/auth/shared/login_flow.go index 99d82b5f7..9cc9d9865 100644 --- a/pkg/cmd/auth/shared/login_flow.go +++ b/pkg/cmd/auth/shared/login_flow.go @@ -8,7 +8,6 @@ import ( "github.com/MakeNowJust/heredoc" "github.com/cli/cli/v2/api" - "github.com/cli/cli/v2/git" "github.com/cli/cli/v2/internal/authflow" "github.com/cli/cli/v2/internal/ghinstance" "github.com/cli/cli/v2/pkg/cmd/ssh-key/add" @@ -28,7 +27,6 @@ type LoginOptions struct { IO *iostreams.IOStreams Config iconfig HTTPClient *http.Client - GitClient *git.Client Hostname string Interactive bool Web bool @@ -65,11 +63,7 @@ func Login(opts *LoginOptions) error { var additionalScopes []string - credentialFlow := &GitCredentialFlow{ - Executable: opts.Executable, - Prompter: opts.Prompter, - GitClient: opts.GitClient, - } + credentialFlow := &GitCredentialFlow{Executable: opts.Executable, Prompter: opts.Prompter} if opts.Interactive && gitProtocol == "https" { if err := credentialFlow.Prompt(hostname); err != nil { return err diff --git a/pkg/cmd/browse/browse.go b/pkg/cmd/browse/browse.go index 85572fdac..ddff8bd92 100644 --- a/pkg/cmd/browse/browse.go +++ b/pkg/cmd/browse/browse.go @@ -1,7 +1,6 @@ package browse import ( - "context" "fmt" "net/http" "net/url" @@ -42,13 +41,11 @@ type BrowseOptions struct { func NewCmdBrowse(f *cmdutil.Factory, runF func(*BrowseOptions) error) *cobra.Command { opts := &BrowseOptions{ - Browser: f.Browser, - HttpClient: f.HttpClient, - IO: f.IOStreams, - PathFromRepoRoot: func() string { - return f.GitClient.PathFromRoot(context.Background()) - }, - GitClient: &localGitClient{client: f.GitClient}, + Browser: f.Browser, + HttpClient: f.HttpClient, + IO: f.IOStreams, + PathFromRepoRoot: git.PathFromRepoRoot, + GitClient: &localGitClient{}, } cmd := &cobra.Command{ @@ -272,18 +269,14 @@ type gitClient interface { LastCommit() (*git.Commit, error) } -type localGitClient struct { - client *git.Client -} +type localGitClient struct{} type remoteGitClient struct { repo func() (ghrepo.Interface, error) httpClient func() (*http.Client, error) } -func (gc *localGitClient) LastCommit() (*git.Commit, error) { - return gc.client.LastCommit(context.Background()) -} +func (gc *localGitClient) LastCommit() (*git.Commit, error) { return git.LastCommit() } func (gc *remoteGitClient) LastCommit() (*git.Commit, error) { httpClient, err := gc.httpClient() diff --git a/pkg/cmd/browse/browse_test.go b/pkg/cmd/browse/browse_test.go index 75e615be0..91197cc96 100644 --- a/pkg/cmd/browse/browse_test.go +++ b/pkg/cmd/browse/browse_test.go @@ -466,7 +466,7 @@ func Test_runBrowse(t *testing.T) { } opts.Browser = &browser if opts.PathFromRepoRoot == nil { - opts.PathFromRepoRoot = func() string { return "" } + opts.PathFromRepoRoot = git.PathFromRepoRoot } err := runBrowse(&opts) diff --git a/pkg/cmd/extension/manager.go b/pkg/cmd/extension/manager.go index c9f638b87..289a9aefa 100644 --- a/pkg/cmd/extension/manager.go +++ b/pkg/cmd/extension/manager.go @@ -2,7 +2,6 @@ package extension import ( "bytes" - "context" _ "embed" "errors" "fmt" @@ -790,8 +789,7 @@ func isBinExtension(client *http.Client, repo ghrepo.Interface) (isBin bool, err } func repoFromPath(path string) (ghrepo.Interface, error) { - gitClient := &git.Client{RepoDir: path} - remotes, err := gitClient.Remotes(context.Background()) + remotes, err := git.RemotesForPath(path) if err != nil { return nil, err } diff --git a/pkg/cmd/factory/default.go b/pkg/cmd/factory/default.go index 10bf5d72b..77552c977 100644 --- a/pkg/cmd/factory/default.go +++ b/pkg/cmd/factory/default.go @@ -1,7 +1,6 @@ package factory import ( - "context" "fmt" "net/http" "os" @@ -9,7 +8,7 @@ import ( "time" "github.com/cli/cli/v2/api" - ghContext "github.com/cli/cli/v2/context" + "github.com/cli/cli/v2/context" "github.com/cli/cli/v2/git" "github.com/cli/cli/v2/internal/browser" "github.com/cli/cli/v2/internal/config" @@ -26,18 +25,18 @@ var ssoURLRE = regexp.MustCompile(`\burl=([^;]+)`) func New(appVersion string) *cmdutil.Factory { f := &cmdutil.Factory{ Config: configFunc(), // No factory dependencies + Branch: branchFunc(), // No factory dependencies ExecutableName: "gh", } f.IOStreams = ioStreams(f) // Depends on Config f.HttpClient = httpClientFunc(f, appVersion) // Depends on Config, IOStreams, and appVersion - f.GitClient = newGitClient(f) // Depends on IOStreams, and Executable - f.Remotes = remotesFunc(f) // Depends on Config, and GitClient + f.Remotes = remotesFunc(f) // Depends on Config f.BaseRepo = BaseRepoFunc(f) // Depends on Remotes f.Prompter = newPrompter(f) // Depends on Config and IOStreams f.Browser = newBrowser(f) // Depends on Config, and IOStreams + f.GitClient = newGitClient(f) // Depends on IOStreams, and Executable f.ExtensionManager = extensionManager(f) // Depends on Config, HttpClient, and IOStreams - f.Branch = branchFunc(f) // Depends on GitClient return f } @@ -65,7 +64,7 @@ func SmartBaseRepoFunc(f *cmdutil.Factory) func() (ghrepo.Interface, error) { if err != nil { return nil, err } - repoContext, err := ghContext.ResolveRemotesToRepos(remotes, apiClient, "") + repoContext, err := context.ResolveRemotesToRepos(remotes, apiClient, "") if err != nil { return nil, err } @@ -78,12 +77,10 @@ func SmartBaseRepoFunc(f *cmdutil.Factory) func() (ghrepo.Interface, error) { } } -func remotesFunc(f *cmdutil.Factory) func() (ghContext.Remotes, error) { +func remotesFunc(f *cmdutil.Factory) func() (context.Remotes, error) { rr := &remoteResolver{ - readRemotes: func() (git.RemoteSet, error) { - return f.GitClient.Remotes(context.Background()) - }, - getConfig: f.Config, + readRemotes: git.Remotes, + getConfig: f.Config, } return rr.Resolver() } @@ -145,9 +142,9 @@ func configFunc() func() (config.Config, error) { } } -func branchFunc(f *cmdutil.Factory) func() (string, error) { +func branchFunc() func() (string, error) { return func() (string, error) { - currentBranch, err := f.GitClient.CurrentBranch(context.Background()) + currentBranch, err := git.CurrentBranch() if err != nil { return "", fmt.Errorf("could not determine current branch: %w", err) } diff --git a/pkg/cmd/gist/clone/clone.go b/pkg/cmd/gist/clone/clone.go index ed074e700..41fa104fa 100644 --- a/pkg/cmd/gist/clone/clone.go +++ b/pkg/cmd/gist/clone/clone.go @@ -1,7 +1,6 @@ package clone import ( - "context" "fmt" "net/http" @@ -17,7 +16,6 @@ import ( type CloneOptions struct { HttpClient func() (*http.Client, error) - GitClient *git.Client Config func() (config.Config, error) IO *iostreams.IOStreams @@ -30,7 +28,6 @@ func NewCmdClone(f *cmdutil.Factory, runF func(*CloneOptions) error) *cobra.Comm opts := &CloneOptions{ IO: f.IOStreams, HttpClient: f.HttpClient, - GitClient: f.GitClient, Config: f.Config, } @@ -87,7 +84,7 @@ func cloneRun(opts *CloneOptions) error { gistURL = formatRemoteURL(hostname, gistURL, protocol) } - _, err := opts.GitClient.Clone(context.Background(), gistURL, opts.GitArgs) + _, err := git.RunClone(gistURL, opts.GitArgs) if err != nil { return err } diff --git a/pkg/cmd/gist/clone/clone_test.go b/pkg/cmd/gist/clone/clone_test.go index cd6f71b6e..ccca76b25 100644 --- a/pkg/cmd/gist/clone/clone_test.go +++ b/pkg/cmd/gist/clone/clone_test.go @@ -5,7 +5,6 @@ import ( "strings" "testing" - "github.com/cli/cli/v2/git" "github.com/cli/cli/v2/internal/config" "github.com/cli/cli/v2/internal/run" "github.com/cli/cli/v2/pkg/cmdutil" @@ -26,7 +25,6 @@ func runCloneCommand(httpClient *http.Client, cli string) (*test.CmdOut, error) Config: func() (config.Config, error) { return config.NewBlankConfig(), nil }, - GitClient: &git.Client{GitPath: "some/path/git"}, } cmd := NewCmdClone(fac, nil) diff --git a/pkg/cmd/pr/checkout/checkout.go b/pkg/cmd/pr/checkout/checkout.go index 8addfc4ae..73c8f40d1 100644 --- a/pkg/cmd/pr/checkout/checkout.go +++ b/pkg/cmd/pr/checkout/checkout.go @@ -19,7 +19,6 @@ import ( type CheckoutOptions struct { HttpClient func() (*http.Client, error) - GitClient *git.Client Config func() (config.Config, error) IO *iostreams.IOStreams Remotes func() (cliContext.Remotes, error) @@ -38,7 +37,6 @@ func NewCmdCheckout(f *cmdutil.Factory, runF func(*CheckoutOptions) error) *cobr opts := &CheckoutOptions{ IO: f.IOStreams, HttpClient: f.HttpClient, - GitClient: f.GitClient, Config: f.Config, Remotes: f.Remotes, Branch: f.Branch, @@ -126,11 +124,11 @@ func checkoutRun(opts *CheckoutOptions) error { } if opts.RecurseSubmodules { - cmdQueue = append(cmdQueue, []string{"submodule", "sync", "--recursive"}) - cmdQueue = append(cmdQueue, []string{"submodule", "update", "--init", "--recursive"}) + cmdQueue = append(cmdQueue, []string{"git", "submodule", "sync", "--recursive"}) + cmdQueue = append(cmdQueue, []string{"git", "submodule", "update", "--init", "--recursive"}) } - err = executeCmds(opts.GitClient, cmdQueue) + err = executeCmds(cmdQueue, opts.IO) if err != nil { return err } @@ -147,7 +145,7 @@ func cmdsForExistingRemote(remote *cliContext.Remote, pr *api.PullRequest, opts refSpec += fmt.Sprintf(":refs/remotes/%s", remoteBranch) } - cmds = append(cmds, []string{"fetch", remote.Name, refSpec}) + cmds = append(cmds, []string{"git", "fetch", remote.Name, refSpec}) localBranch := pr.HeadRefName if opts.BranchName != "" { @@ -156,17 +154,17 @@ func cmdsForExistingRemote(remote *cliContext.Remote, pr *api.PullRequest, opts switch { case opts.Detach: - cmds = append(cmds, []string{"checkout", "--detach", "FETCH_HEAD"}) - case localBranchExists(opts.GitClient, localBranch): - cmds = append(cmds, []string{"checkout", localBranch}) + cmds = append(cmds, []string{"git", "checkout", "--detach", "FETCH_HEAD"}) + case localBranchExists(localBranch): + cmds = append(cmds, []string{"git", "checkout", localBranch}) if opts.Force { - cmds = append(cmds, []string{"reset", "--hard", fmt.Sprintf("refs/remotes/%s", remoteBranch)}) + cmds = append(cmds, []string{"git", "reset", "--hard", fmt.Sprintf("refs/remotes/%s", remoteBranch)}) } else { // TODO: check if non-fast-forward and suggest to use `--force` - cmds = append(cmds, []string{"merge", "--ff-only", fmt.Sprintf("refs/remotes/%s", remoteBranch)}) + cmds = append(cmds, []string{"git", "merge", "--ff-only", fmt.Sprintf("refs/remotes/%s", remoteBranch)}) } default: - cmds = append(cmds, []string{"checkout", "-b", localBranch, "--track", remoteBranch}) + cmds = append(cmds, []string{"git", "checkout", "-b", localBranch, "--track", remoteBranch}) } return cmds @@ -177,8 +175,8 @@ func cmdsForMissingRemote(pr *api.PullRequest, baseURLOrName, repoHost, defaultB ref := fmt.Sprintf("refs/pull/%d/head", pr.Number) if opts.Detach { - cmds = append(cmds, []string{"fetch", baseURLOrName, ref}) - cmds = append(cmds, []string{"checkout", "--detach", "FETCH_HEAD"}) + cmds = append(cmds, []string{"git", "fetch", baseURLOrName, ref}) + cmds = append(cmds, []string{"git", "checkout", "--detach", "FETCH_HEAD"}) return cmds } @@ -193,22 +191,22 @@ func cmdsForMissingRemote(pr *api.PullRequest, baseURLOrName, repoHost, defaultB currentBranch, _ := opts.Branch() if localBranch == currentBranch { // PR head matches currently checked out branch - cmds = append(cmds, []string{"fetch", baseURLOrName, ref}) + cmds = append(cmds, []string{"git", "fetch", baseURLOrName, ref}) if opts.Force { - cmds = append(cmds, []string{"reset", "--hard", "FETCH_HEAD"}) + cmds = append(cmds, []string{"git", "reset", "--hard", "FETCH_HEAD"}) } else { // TODO: check if non-fast-forward and suggest to use `--force` - cmds = append(cmds, []string{"merge", "--ff-only", "FETCH_HEAD"}) + cmds = append(cmds, []string{"git", "merge", "--ff-only", "FETCH_HEAD"}) } } else { if opts.Force { - cmds = append(cmds, []string{"fetch", baseURLOrName, fmt.Sprintf("%s:%s", ref, localBranch), "--force"}) + cmds = append(cmds, []string{"git", "fetch", baseURLOrName, fmt.Sprintf("%s:%s", ref, localBranch), "--force"}) } else { // TODO: check if non-fast-forward and suggest to use `--force` - cmds = append(cmds, []string{"fetch", baseURLOrName, fmt.Sprintf("%s:%s", ref, localBranch)}) + cmds = append(cmds, []string{"git", "fetch", baseURLOrName, fmt.Sprintf("%s:%s", ref, localBranch)}) } - cmds = append(cmds, []string{"checkout", localBranch}) + cmds = append(cmds, []string{"git", "checkout", localBranch}) } remote := baseURLOrName @@ -218,32 +216,37 @@ func cmdsForMissingRemote(pr *api.PullRequest, baseURLOrName, repoHost, defaultB remote = ghrepo.FormatRemoteURL(headRepo, protocol) mergeRef = fmt.Sprintf("refs/heads/%s", pr.HeadRefName) } - if missingMergeConfigForBranch(opts.GitClient, localBranch) { + if missingMergeConfigForBranch(localBranch) { // .remote is needed for `git pull` to work // .pushRemote is needed for `git push` to work, if user has set `remote.pushDefault`. // see https://git-scm.com/docs/git-config#Documentation/git-config.txt-branchltnamegtremote - cmds = append(cmds, []string{"config", fmt.Sprintf("branch.%s.remote", localBranch), remote}) - cmds = append(cmds, []string{"config", fmt.Sprintf("branch.%s.pushRemote", localBranch), remote}) - cmds = append(cmds, []string{"config", fmt.Sprintf("branch.%s.merge", localBranch), mergeRef}) + cmds = append(cmds, []string{"git", "config", fmt.Sprintf("branch.%s.remote", localBranch), remote}) + cmds = append(cmds, []string{"git", "config", fmt.Sprintf("branch.%s.pushRemote", localBranch), remote}) + cmds = append(cmds, []string{"git", "config", fmt.Sprintf("branch.%s.merge", localBranch), mergeRef}) } return cmds } -func missingMergeConfigForBranch(client *git.Client, b string) bool { - mc, err := client.Config(context.Background(), fmt.Sprintf("branch.%s.merge", b)) +func missingMergeConfigForBranch(b string) bool { + mc, err := git.Config(fmt.Sprintf("branch.%s.merge", b)) return err != nil || mc == "" } -func localBranchExists(client *git.Client, b string) bool { - _, err := client.ShowRefs(context.Background(), "refs/heads/"+b) +func localBranchExists(b string) bool { + _, err := git.ShowRefs("refs/heads/" + b) return err == nil } -func executeCmds(client *git.Client, cmdQueue [][]string) error { +func executeCmds(cmdQueue [][]string, ios *iostreams.IOStreams) error { + //TODO: Replace with factory GitClient + //TODO: Use AuthenticatedCommand + client := git.Client{ + Stdout: ios.Out, + Stderr: ios.ErrOut, + } for _, args := range cmdQueue { - //TODO: Use AuthenticatedCommand - cmd, err := client.Command(context.Background(), args...) + cmd, err := client.Command(context.Background(), args[1:]...) if err != nil { return err } diff --git a/pkg/cmd/pr/checkout/checkout_test.go b/pkg/cmd/pr/checkout/checkout_test.go index 7540bbacf..df77e6676 100644 --- a/pkg/cmd/pr/checkout/checkout_test.go +++ b/pkg/cmd/pr/checkout/checkout_test.go @@ -197,8 +197,6 @@ func Test_checkoutRun(t *testing.T) { return remotes, nil } - opts.GitClient = &git.Client{GitPath: "some/path/git"} - err := checkoutRun(opts) if (err != nil) != tt.wantErr { t.Errorf("want error: %v, got: %v", tt.wantErr, err) @@ -236,7 +234,6 @@ func runCommand(rt http.RoundTripper, remotes context.Remotes, branch string, cl Branch: func() (string, error) { return branch, nil }, - GitClient: &git.Client{GitPath: "some/path/git"}, } cmd := NewCmdCheckout(factory, nil) diff --git a/pkg/cmd/pr/close/close.go b/pkg/cmd/pr/close/close.go index 7e39005bd..7d80199b6 100644 --- a/pkg/cmd/pr/close/close.go +++ b/pkg/cmd/pr/close/close.go @@ -1,7 +1,6 @@ package close import ( - "context" "fmt" "net/http" @@ -16,7 +15,6 @@ import ( type CloseOptions struct { HttpClient func() (*http.Client, error) - GitClient *git.Client IO *iostreams.IOStreams Branch func() (string, error) @@ -32,7 +30,6 @@ func NewCmdClose(f *cmdutil.Factory, runF func(*CloseOptions) error) *cobra.Comm opts := &CloseOptions{ IO: f.IOStreams, HttpClient: f.HttpClient, - GitClient: f.GitClient, Branch: f.Branch, } @@ -111,10 +108,9 @@ func closeRun(opts *CloseOptions) error { fmt.Fprintf(opts.IO.ErrOut, "%s Closed pull request #%d (%s)\n", cs.SuccessIconWithColor(cs.Red), pr.Number, pr.Title) if opts.DeleteBranch { - ctx := context.Background() branchSwitchString := "" apiClient := api.NewClientFromHTTP(httpClient) - localBranchExists := opts.GitClient.HasLocalBranch(ctx, pr.HeadRefName) + localBranchExists := git.HasLocalBranch(pr.HeadRefName) if opts.DeleteLocalBranch { if localBranchExists { @@ -129,13 +125,13 @@ func closeRun(opts *CloseOptions) error { if err != nil { return err } - err = opts.GitClient.CheckoutBranch(ctx, branchToSwitchTo) + err = git.CheckoutBranch(branchToSwitchTo) if err != nil { return err } } - if err := opts.GitClient.DeleteLocalBranch(ctx, pr.HeadRefName); err != nil { + if err := git.DeleteLocalBranch(pr.HeadRefName); err != nil { return fmt.Errorf("failed to delete local branch %s: %w", cs.Cyan(pr.HeadRefName), err) } diff --git a/pkg/cmd/pr/close/close_test.go b/pkg/cmd/pr/close/close_test.go index b435fb8aa..df9585fbb 100644 --- a/pkg/cmd/pr/close/close_test.go +++ b/pkg/cmd/pr/close/close_test.go @@ -9,7 +9,6 @@ import ( "github.com/MakeNowJust/heredoc" "github.com/cli/cli/v2/api" - "github.com/cli/cli/v2/git" "github.com/cli/cli/v2/internal/ghrepo" "github.com/cli/cli/v2/internal/run" "github.com/cli/cli/v2/pkg/cmd/pr/shared" @@ -73,7 +72,6 @@ func runCommand(rt http.RoundTripper, isTTY bool, cli string) (*test.CmdOut, err Branch: func() (string, error) { return "trunk", nil }, - GitClient: &git.Client{GitPath: "some/path/git"}, } cmd := NewCmdClose(factory, nil) diff --git a/pkg/cmd/pr/create/create.go b/pkg/cmd/pr/create/create.go index 75d4b58f1..e9c664b88 100644 --- a/pkg/cmd/pr/create/create.go +++ b/pkg/cmd/pr/create/create.go @@ -1,7 +1,6 @@ package create import ( - "context" "errors" "fmt" "net/http" @@ -13,7 +12,7 @@ import ( "github.com/AlecAivazis/survey/v2" "github.com/MakeNowJust/heredoc" "github.com/cli/cli/v2/api" - ghContext "github.com/cli/cli/v2/context" + "github.com/cli/cli/v2/context" "github.com/cli/cli/v2/git" "github.com/cli/cli/v2/internal/browser" "github.com/cli/cli/v2/internal/config" @@ -33,10 +32,9 @@ type iprompter interface { type CreateOptions struct { // This struct stores user input and factory functions HttpClient func() (*http.Client, error) - GitClient *git.Client Config func() (config.Config, error) IO *iostreams.IOStreams - Remotes func() (ghContext.Remotes, error) + Remotes func() (context.Remotes, error) Branch func() (string, error) Browser browser.Browser Prompter iprompter @@ -70,24 +68,22 @@ type CreateOptions struct { type CreateContext struct { // This struct stores contextual data about the creation process and is for building up enough // data to create a pull request - RepoContext *ghContext.ResolvedRemotes + RepoContext *context.ResolvedRemotes BaseRepo *api.Repository HeadRepo ghrepo.Interface BaseTrackingBranch string BaseBranch string HeadBranch string HeadBranchLabel string - HeadRemote *ghContext.Remote + HeadRemote *context.Remote IsPushEnabled bool Client *api.Client - GitClient *git.Client } func NewCmdCreate(f *cmdutil.Factory, runF func(*CreateOptions) error) *cobra.Command { opts := &CreateOptions{ IO: f.IOStreams, HttpClient: f.HttpClient, - GitClient: f.GitClient, Config: f.Config, Remotes: f.Remotes, Branch: f.Branch, @@ -373,16 +369,15 @@ func createRun(opts *CreateOptions) (err error) { func initDefaultTitleBody(ctx CreateContext, state *shared.IssueMetadataState) error { baseRef := ctx.BaseTrackingBranch headRef := ctx.HeadBranch - gitClient := ctx.GitClient - commits, err := gitClient.Commits(context.Background(), baseRef, headRef) + commits, err := git.Commits(baseRef, headRef) if err != nil { return err } if len(commits) == 1 { state.Title = commits[0].Title - body, err := gitClient.CommitBody(context.Background(), commits[0].Sha) + body, err := git.CommitBody(commits[0].Sha) if err != nil { return err } @@ -400,11 +395,11 @@ func initDefaultTitleBody(ctx CreateContext, state *shared.IssueMetadataState) e return nil } -func determineTrackingBranch(gitClient *git.Client, remotes ghContext.Remotes, headBranch string) *git.TrackingRef { +func determineTrackingBranch(remotes context.Remotes, headBranch string) *git.TrackingRef { refsForLookup := []string{"HEAD"} var trackingRefs []git.TrackingRef - headBranchConfig := gitClient.ReadBranchConfig(context.Background(), headBranch) + headBranchConfig := git.ReadBranchConfig(headBranch) if headBranchConfig.RemoteName != "" { tr := git.TrackingRef{ RemoteName: headBranchConfig.RemoteName, @@ -423,7 +418,7 @@ func determineTrackingBranch(gitClient *git.Client, remotes ghContext.Remotes, h refsForLookup = append(refsForLookup, tr.String()) } - resolvedRefs, _ := gitClient.ShowRefs(context.Background(), refsForLookup...) + resolvedRefs, _ := git.ShowRefs(refsForLookup...) if len(resolvedRefs) > 1 { for _, r := range resolvedRefs[1:] { if r.Hash != resolvedRefs[0].Hash { @@ -485,7 +480,7 @@ func NewCreateContext(opts *CreateOptions) (*CreateContext, error) { return nil, err } - repoContext, err := ghContext.ResolveRemotesToRepos(remotes, client, opts.RepoOverride) + repoContext, err := context.ResolveRemotesToRepos(remotes, client, opts.RepoOverride) if err != nil { return nil, err } @@ -520,17 +515,16 @@ func NewCreateContext(opts *CreateOptions) (*CreateContext, error) { headBranch = headBranch[idx+1:] } - gitClient := opts.GitClient - if ucc, err := gitClient.UncommittedChangeCount(context.Background()); err == nil && ucc > 0 { + if ucc, err := git.UncommittedChangeCount(); err == nil && ucc > 0 { fmt.Fprintf(opts.IO.ErrOut, "Warning: %s\n", text.Pluralize(ucc, "uncommitted change")) } var headRepo ghrepo.Interface - var headRemote *ghContext.Remote + var headRemote *context.Remote if isPushEnabled { // determine whether the head branch is already pushed to a remote - if pushedTo := determineTrackingBranch(gitClient, remotes, headBranch); pushedTo != nil { + if pushedTo := determineTrackingBranch(remotes, headBranch); pushedTo != nil { isPushEnabled = false if r, err := remotes.FindByName(pushedTo.RemoteName); err == nil { headRepo = r @@ -631,7 +625,6 @@ func NewCreateContext(opts *CreateOptions) (*CreateContext, error) { IsPushEnabled: isPushEnabled, RepoContext: repoContext, Client: client, - GitClient: gitClient, }, nil } @@ -720,12 +713,11 @@ func handlePush(opts CreateOptions, ctx CreateContext) error { headRepoURL := ghrepo.FormatRemoteURL(headRepo, cloneProtocol) // TODO: prevent clashes with another remote of a same name - gitClient := ctx.GitClient - gitRemote, err := gitClient.AddRemote(context.Background(), "fork", headRepoURL, []string{}) + gitRemote, err := git.AddRemote("fork", headRepoURL) if err != nil { return fmt.Errorf("error adding remote: %w", err) } - headRemote = &ghContext.Remote{ + headRemote = &context.Remote{ Remote: gitRemote, Repo: headRepo, } @@ -737,11 +729,12 @@ func handlePush(opts CreateOptions, ctx CreateContext) error { pushTries := 0 maxPushTries := 3 for { - w := NewRegexpWriter(opts.IO.ErrOut, gitPushRegexp, "") - defer w.Flush() - gitClient := ctx.GitClient - ref := fmt.Sprintf("HEAD:%s", ctx.HeadBranch) - if err := gitClient.Push(context.Background(), headRemote.Name, ref, git.WithStderr(w)); err != nil { + r := NewRegexpWriter(opts.IO.ErrOut, gitPushRegexp, "") + defer r.Flush() + cmdErr := r + cmdIn := opts.IO.In + cmdOut := opts.IO.Out + if err := git.Push(headRemote.Name, fmt.Sprintf("HEAD:%s", ctx.HeadBranch), cmdIn, cmdOut, cmdErr); err != nil { if didForkRepo && pushTries < maxPushTries { pushTries++ // first wait 2 seconds after forking, then 4s, then 6s diff --git a/pkg/cmd/pr/create/create_test.go b/pkg/cmd/pr/create/create_test.go index 0764ffe3b..c83dd13a2 100644 --- a/pkg/cmd/pr/create/create_test.go +++ b/pkg/cmd/pr/create/create_test.go @@ -869,7 +869,6 @@ func Test_createRun(t *testing.T) { return branch, nil } opts.Finder = shared.NewMockFinder(branch, nil, nil) - opts.GitClient = &git.Client{GitPath: "some/path/git"} cleanSetup := func() {} if tt.setup != nil { cleanSetup = tt.setup(&opts, t) @@ -986,8 +985,7 @@ func Test_determineTrackingBranch(t *testing.T) { tt.cmdStubs(cs) - gitClient := &git.Client{GitPath: "some/path/git"} - ref := determineTrackingBranch(gitClient, tt.remotes, "feature") + ref := determineTrackingBranch(tt.remotes, "feature") tt.assert(ref, t) }) } diff --git a/pkg/cmd/pr/merge/merge.go b/pkg/cmd/pr/merge/merge.go index dbf0923f7..2972c09f9 100644 --- a/pkg/cmd/pr/merge/merge.go +++ b/pkg/cmd/pr/merge/merge.go @@ -1,7 +1,6 @@ package merge import ( - "context" "errors" "fmt" "net/http" @@ -9,7 +8,7 @@ import ( "github.com/AlecAivazis/survey/v2" "github.com/MakeNowJust/heredoc" "github.com/cli/cli/v2/api" - ghContext "github.com/cli/cli/v2/context" + "github.com/cli/cli/v2/context" "github.com/cli/cli/v2/git" "github.com/cli/cli/v2/internal/config" "github.com/cli/cli/v2/internal/ghrepo" @@ -27,10 +26,9 @@ type editor interface { type MergeOptions struct { HttpClient func() (*http.Client, error) - GitClient *git.Client IO *iostreams.IOStreams Branch func() (string, error) - Remotes func() (ghContext.Remotes, error) + Remotes func() (context.Remotes, error) Finder shared.PRFinder @@ -62,7 +60,6 @@ func NewCmdMerge(f *cmdutil.Factory, runF func(*MergeOptions) error) *cobra.Comm opts := &MergeOptions{ IO: f.IOStreams, HttpClient: f.HttpClient, - GitClient: f.GitClient, Branch: f.Branch, Remotes: f.Remotes, } @@ -227,7 +224,7 @@ func (m *mergeContext) warnIfDiverged() { return } - localBranchLastCommit, err := m.opts.GitClient.LastCommit(context.Background()) + localBranchLastCommit, err := git.LastCommit() if err != nil { return } @@ -399,8 +396,6 @@ func (m *mergeContext) deleteLocalBranch() error { return err } - ctx := context.Background() - // branch the command was run on is the same as the pull request branch if currentBranch == m.pr.HeadRefName { remotes, err := m.opts.Remotes() @@ -414,24 +409,24 @@ func (m *mergeContext) deleteLocalBranch() error { } targetBranch := m.pr.BaseRefName - if m.opts.GitClient.HasLocalBranch(ctx, targetBranch) { - if err := m.opts.GitClient.CheckoutBranch(ctx, targetBranch); err != nil { + if git.HasLocalBranch(targetBranch) { + if err := git.CheckoutBranch(targetBranch); err != nil { return err } } else { - if err := m.opts.GitClient.CheckoutNewBranch(ctx, baseRemote.Name, targetBranch); err != nil { + if err := git.CheckoutNewBranch(baseRemote.Name, targetBranch); err != nil { return err } } - if err := m.opts.GitClient.Pull(ctx, baseRemote.Name, targetBranch); err != nil { + if err := git.Pull(baseRemote.Name, targetBranch); err != nil { _ = m.warnf(fmt.Sprintf("%s warning: not possible to fast-forward to: %q\n", m.cs.WarningIcon(), targetBranch)) } m.switchedToBranch = targetBranch } - if err := m.opts.GitClient.DeleteLocalBranch(ctx, m.pr.HeadRefName); err != nil { + if err := git.DeleteLocalBranch(m.pr.HeadRefName); err != nil { return fmt.Errorf("failed to delete local branch %s: %w", m.cs.Cyan(m.pr.HeadRefName), err) } @@ -508,7 +503,7 @@ func NewMergeContext(opts *MergeOptions) (*mergeContext, error) { deleteBranch: opts.DeleteBranch, crossRepoPR: pr.HeadRepositoryOwner.Login != baseRepo.RepoOwner(), autoMerge: opts.AutoMergeEnable && !isImmediatelyMergeable(pr.MergeStateStatus), - localBranchExists: opts.CanDeleteLocalBranch && opts.GitClient.HasLocalBranch(context.Background(), pr.HeadRefName), + localBranchExists: opts.CanDeleteLocalBranch && git.HasLocalBranch(pr.HeadRefName), mergeQueueRequired: pr.IsMergeQueueEnabled, }, nil } @@ -735,7 +730,7 @@ func allowsAdminOverride(status string) bool { } } -func remoteForMergeConflictResolution(baseRepo ghrepo.Interface, pr *api.PullRequest, opts *MergeOptions) *ghContext.Remote { +func remoteForMergeConflictResolution(baseRepo ghrepo.Interface, pr *api.PullRequest, opts *MergeOptions) *context.Remote { if !mergeConflictStatus(pr.MergeStateStatus) || !opts.CanDeleteLocalBranch { return nil } diff --git a/pkg/cmd/pr/merge/merge_test.go b/pkg/cmd/pr/merge/merge_test.go index 0e3ced350..d1bb21d8c 100644 --- a/pkg/cmd/pr/merge/merge_test.go +++ b/pkg/cmd/pr/merge/merge_test.go @@ -270,7 +270,6 @@ func runCommand(rt http.RoundTripper, branch string, isTTY bool, cli string) (*t }, }, nil }, - GitClient: &git.Client{GitPath: "some/path/git"}, } cmd := NewCmdMerge(factory, nil) diff --git a/pkg/cmd/pr/shared/finder.go b/pkg/cmd/pr/shared/finder.go index ed04c4295..b700a7501 100644 --- a/pkg/cmd/pr/shared/finder.go +++ b/pkg/cmd/pr/shared/finder.go @@ -53,14 +53,12 @@ func NewFinder(factory *cmdutil.Factory) PRFinder { } return &finder{ - baseRepoFn: factory.BaseRepo, - branchFn: factory.Branch, - remotesFn: factory.Remotes, - httpClient: factory.HttpClient, - progress: factory.IOStreams, - branchConfig: func(s string) git.BranchConfig { - return factory.GitClient.ReadBranchConfig(context.Background(), s) - }, + baseRepoFn: factory.BaseRepo, + branchFn: factory.Branch, + remotesFn: factory.Remotes, + httpClient: factory.HttpClient, + progress: factory.IOStreams, + branchConfig: git.ReadBranchConfig, } } diff --git a/pkg/cmd/pr/shared/survey.go b/pkg/cmd/pr/shared/survey.go index dd4ba085d..31942c6a0 100644 --- a/pkg/cmd/pr/shared/survey.go +++ b/pkg/cmd/pr/shared/survey.go @@ -6,7 +6,9 @@ import ( "github.com/AlecAivazis/survey/v2" "github.com/cli/cli/v2/api" + "github.com/cli/cli/v2/git" "github.com/cli/cli/v2/internal/ghrepo" + "github.com/cli/cli/v2/pkg/githubtemplate" "github.com/cli/cli/v2/pkg/iostreams" "github.com/cli/cli/v2/pkg/prompt" "github.com/cli/cli/v2/pkg/surveyext" @@ -367,3 +369,18 @@ func MetadataSurvey(io *iostreams.IOStreams, baseRepo ghrepo.Interface, fetcher return nil } + +func FindTemplates(dir, path string) ([]string, string) { + if dir == "" { + rootDir, err := git.ToplevelDir() + if err != nil { + return []string{}, "" + } + dir = rootDir + } + + templateFiles := githubtemplate.FindNonLegacy(dir, path) + legacyTemplate := githubtemplate.FindLegacy(dir, path) + + return templateFiles, legacyTemplate +} diff --git a/pkg/cmd/pr/shared/templates.go b/pkg/cmd/pr/shared/templates.go index 7aab57978..975bdd5da 100644 --- a/pkg/cmd/pr/shared/templates.go +++ b/pkg/cmd/pr/shared/templates.go @@ -1,7 +1,6 @@ package shared import ( - "context" "fmt" "net/http" "time" @@ -234,8 +233,7 @@ func (m *templateManager) fetch() error { dir := m.rootDir if dir == "" { var err error - gitClient := &git.Client{} - dir, err = gitClient.ToplevelDir(context.Background()) + dir, err = git.ToplevelDir() if err != nil { return nil // abort silently } diff --git a/pkg/cmd/pr/status/status.go b/pkg/cmd/pr/status/status.go index e4434978b..784159564 100644 --- a/pkg/cmd/pr/status/status.go +++ b/pkg/cmd/pr/status/status.go @@ -1,7 +1,6 @@ package status import ( - "context" "errors" "fmt" "net/http" @@ -10,7 +9,7 @@ import ( "strings" "github.com/cli/cli/v2/api" - ghContext "github.com/cli/cli/v2/context" + "github.com/cli/cli/v2/context" "github.com/cli/cli/v2/git" "github.com/cli/cli/v2/internal/config" "github.com/cli/cli/v2/internal/ghrepo" @@ -23,11 +22,10 @@ import ( type StatusOptions struct { HttpClient func() (*http.Client, error) - GitClient *git.Client Config func() (config.Config, error) IO *iostreams.IOStreams BaseRepo func() (ghrepo.Interface, error) - Remotes func() (ghContext.Remotes, error) + Remotes func() (context.Remotes, error) Branch func() (string, error) HasRepoOverride bool @@ -39,7 +37,6 @@ func NewCmdStatus(f *cmdutil.Factory, runF func(*StatusOptions) error) *cobra.Co opts := &StatusOptions{ IO: f.IOStreams, HttpClient: f.HttpClient, - GitClient: f.GitClient, Config: f.Config, Remotes: f.Remotes, Branch: f.Branch, @@ -89,7 +86,7 @@ func statusRun(opts *StatusOptions) error { } remotes, _ := opts.Remotes() - currentPRNumber, currentPRHeadRef, err = prSelectorForCurrentBranch(opts.GitClient, baseRepo, currentBranch, remotes) + currentPRNumber, currentPRHeadRef, err = prSelectorForCurrentBranch(baseRepo, currentBranch, remotes) if err != nil { return fmt.Errorf("could not query for pull request for current branch: %w", err) } @@ -168,9 +165,9 @@ func statusRun(opts *StatusOptions) error { return nil } -func prSelectorForCurrentBranch(gitClient *git.Client, baseRepo ghrepo.Interface, prHeadRef string, rem ghContext.Remotes) (prNumber int, selector string, err error) { +func prSelectorForCurrentBranch(baseRepo ghrepo.Interface, prHeadRef string, rem context.Remotes) (prNumber int, selector string, err error) { selector = prHeadRef - branchConfig := gitClient.ReadBranchConfig(context.Background(), prHeadRef) + branchConfig := git.ReadBranchConfig(prHeadRef) // the branch is configured to merge a special PR head ref prHeadRE := regexp.MustCompile(`^refs/pull/(\d+)/head$`) diff --git a/pkg/cmd/pr/status/status_test.go b/pkg/cmd/pr/status/status_test.go index c3187739d..31f28396e 100644 --- a/pkg/cmd/pr/status/status_test.go +++ b/pkg/cmd/pr/status/status_test.go @@ -52,7 +52,6 @@ func runCommand(rt http.RoundTripper, branch string, isTTY bool, cli string) (*t } return branch, nil }, - GitClient: &git.Client{GitPath: "some/path/git"}, } cmd := NewCmdStatus(factory, nil) @@ -329,8 +328,7 @@ func Test_prSelectorForCurrentBranch(t *testing.T) { Repo: repo, }, } - gitClient := &git.Client{GitPath: "some/path/git"} - prNum, headRef, err := prSelectorForCurrentBranch(gitClient, repo, "Frederick888/main", rem) + prNum, headRef, err := prSelectorForCurrentBranch(repo, "Frederick888/main", rem) if err != nil { t.Fatalf("prSelectorForCurrentBranch error: %v", err) } diff --git a/pkg/cmd/release/create/create.go b/pkg/cmd/release/create/create.go index 8c36218da..e6f97dd9f 100644 --- a/pkg/cmd/release/create/create.go +++ b/pkg/cmd/release/create/create.go @@ -2,7 +2,6 @@ package create import ( "bytes" - "context" "errors" "fmt" "io" @@ -27,7 +26,6 @@ type CreateOptions struct { IO *iostreams.IOStreams Config func() (config.Config, error) HttpClient func() (*http.Client, error) - GitClient *git.Client BaseRepo func() (ghrepo.Interface, error) Edit func(string, string, string, io.Reader, io.Writer, io.Writer) (string, error) @@ -58,7 +56,6 @@ func NewCmdCreate(f *cmdutil.Factory, runF func(*CreateOptions) error) *cobra.Co opts := &CreateOptions{ IO: f.IOStreams, HttpClient: f.HttpClient, - GitClient: f.GitClient, Config: f.Config, Edit: surveyext.Edit, } @@ -224,7 +221,7 @@ func createRun(opts *CreateOptions) error { var tagDescription string if opts.RepoOverride == "" { - tagDescription, _ = gitTagInfo(opts.GitClient, opts.TagName) + tagDescription, _ = gitTagInfo(opts.TagName) // If there is a local tag with the same name as specified // the user may not want to create a new tag on the remote // as the local one might be annotated or signed. @@ -271,10 +268,10 @@ func createRun(opts *CreateOptions) error { } if generatedNotes == nil { if opts.NotesStartTag != "" { - commits, _ := changelogForRange(opts.GitClient, fmt.Sprintf("%s..%s", opts.NotesStartTag, headRef)) + commits, _ := changelogForRange(fmt.Sprintf("%s..%s", opts.NotesStartTag, headRef)) generatedChangelog = generateChangelog(commits) - } else if prevTag, err := detectPreviousTag(opts.GitClient, headRef); err == nil { - commits, _ := changelogForRange(opts.GitClient, fmt.Sprintf("%s..%s", prevTag, headRef)) + } else if prevTag, err := detectPreviousTag(headRef); err == nil { + commits, _ := changelogForRange(fmt.Sprintf("%s..%s", prevTag, headRef)) generatedChangelog = generateChangelog(commits) } } @@ -472,8 +469,8 @@ func createRun(opts *CreateOptions) error { return nil } -func gitTagInfo(client *git.Client, tagName string) (string, error) { - cmd, err := client.Command(context.Background(), "tag", "--list", tagName, "--format=%(contents:subject)%0a%0a%(contents:body)") +func gitTagInfo(tagName string) (string, error) { + cmd, err := git.GitCommand("tag", "--list", tagName, "--format=%(contents:subject)%0a%0a%(contents:body)") if err != nil { return "", err } @@ -481,8 +478,8 @@ func gitTagInfo(client *git.Client, tagName string) (string, error) { return string(b), err } -func detectPreviousTag(client *git.Client, headRef string) (string, error) { - cmd, err := client.Command(context.Background(), "describe", "--tags", "--abbrev=0", fmt.Sprintf("%s^", headRef)) +func detectPreviousTag(headRef string) (string, error) { + cmd, err := git.GitCommand("describe", "--tags", "--abbrev=0", fmt.Sprintf("%s^", headRef)) if err != nil { return "", err } @@ -495,8 +492,8 @@ type logEntry struct { Body string } -func changelogForRange(client *git.Client, refRange string) ([]logEntry, error) { - cmd, err := client.Command(context.Background(), "-c", "log.ShowSignature=false", "log", "--first-parent", "--reverse", "--pretty=format:%B%x00", refRange) +func changelogForRange(refRange string) ([]logEntry, error) { + cmd, err := git.GitCommand("-c", "log.ShowSignature=false", "log", "--first-parent", "--reverse", "--pretty=format:%B%x00", refRange) if err != nil { return nil, err } diff --git a/pkg/cmd/release/create/create_test.go b/pkg/cmd/release/create/create_test.go index a6424c0d6..7fb6711b2 100644 --- a/pkg/cmd/release/create/create_test.go +++ b/pkg/cmd/release/create/create_test.go @@ -10,7 +10,6 @@ import ( "path/filepath" "testing" - "github.com/cli/cli/v2/git" "github.com/cli/cli/v2/internal/config" "github.com/cli/cli/v2/internal/ghrepo" "github.com/cli/cli/v2/internal/run" @@ -689,8 +688,6 @@ func Test_createRun(t *testing.T) { return ghrepo.FromFullName("OWNER/REPO") } - tt.opts.GitClient = &git.Client{GitPath: "some/path/git"} - err := createRun(&tt.opts) if tt.wantErr != "" { require.EqualError(t, err, tt.wantErr) @@ -1053,8 +1050,6 @@ func Test_createRun_interactive(t *testing.T) { return val, nil } - tt.opts.GitClient = &git.Client{GitPath: "some/path/git"} - t.Run(tt.name, func(t *testing.T) { //nolint:staticcheck // SA1019: prompt.NewAskStubber is deprecated: use PrompterMock as := prompt.NewAskStubber(t) diff --git a/pkg/cmd/repo/clone/clone.go b/pkg/cmd/repo/clone/clone.go index cb46d4572..ba289aa47 100644 --- a/pkg/cmd/repo/clone/clone.go +++ b/pkg/cmd/repo/clone/clone.go @@ -1,7 +1,6 @@ package clone import ( - "context" "fmt" "net/http" "strings" @@ -19,7 +18,6 @@ import ( type CloneOptions struct { HttpClient func() (*http.Client, error) - GitClient *git.Client Config func() (config.Config, error) IO *iostreams.IOStreams @@ -32,7 +30,6 @@ func NewCmdClone(f *cmdutil.Factory, runF func(*CloneOptions) error) *cobra.Comm opts := &CloneOptions{ IO: f.IOStreams, HttpClient: f.HttpClient, - GitClient: f.GitClient, Config: f.Config, } @@ -155,9 +152,7 @@ func cloneRun(opts *CloneOptions) error { canonicalCloneURL = strings.TrimSuffix(canonicalCloneURL, ".git") + ".wiki.git" } - gitClient := opts.GitClient - ctx := context.Background() - cloneDir, err := gitClient.Clone(ctx, canonicalCloneURL, opts.GitArgs) + cloneDir, err := git.RunClone(canonicalCloneURL, opts.GitArgs) if err != nil { return err } @@ -175,7 +170,7 @@ func cloneRun(opts *CloneOptions) error { upstreamName = canonicalRepo.Parent.RepoOwner() } - _, err = gitClient.AddRemote(ctx, upstreamName, upstreamURL, []string{canonicalRepo.Parent.DefaultBranchRef.Name}, git.WithRepoDir(cloneDir)) + err = git.AddNamedRemote(upstreamURL, upstreamName, cloneDir, []string{canonicalRepo.Parent.DefaultBranchRef.Name}) if err != nil { return err } diff --git a/pkg/cmd/repo/clone/clone_test.go b/pkg/cmd/repo/clone/clone_test.go index c6ec872be..34f2fe431 100644 --- a/pkg/cmd/repo/clone/clone_test.go +++ b/pkg/cmd/repo/clone/clone_test.go @@ -5,7 +5,6 @@ import ( "strings" "testing" - "github.com/cli/cli/v2/git" "github.com/cli/cli/v2/internal/config" "github.com/cli/cli/v2/internal/run" "github.com/cli/cli/v2/pkg/cmdutil" @@ -105,7 +104,6 @@ func runCloneCommand(httpClient *http.Client, cli string) (*test.CmdOut, error) Config: func() (config.Config, error) { return config.NewBlankConfig(), nil }, - GitClient: &git.Client{GitPath: "some/path/git"}, } cmd := NewCmdClone(fac, nil) diff --git a/pkg/cmd/repo/create/create.go b/pkg/cmd/repo/create/create.go index 931ffb926..e311ad727 100644 --- a/pkg/cmd/repo/create/create.go +++ b/pkg/cmd/repo/create/create.go @@ -1,7 +1,6 @@ package create import ( - "context" "errors" "fmt" "net/http" @@ -28,7 +27,6 @@ type iprompter interface { type CreateOptions struct { HttpClient func() (*http.Client, error) - GitClient *git.Client Config func() (config.Config, error) IO *iostreams.IOStreams Prompter iprompter @@ -59,7 +57,6 @@ func NewCmdCreate(f *cmdutil.Factory, runF func(*CreateOptions) error) *cobra.Co opts := &CreateOptions{ IO: f.IOStreams, HttpClient: f.HttpClient, - GitClient: f.GitClient, Config: f.Config, Prompter: f.Prompter, } @@ -393,10 +390,10 @@ func createFromScratch(opts *CreateOptions) error { // use the template's default branch checkoutBranch = templateRepoMainBranch } - if err := localInit(opts.GitClient, remoteURL, repo.RepoName(), checkoutBranch); err != nil { + if err := localInit(opts.IO, remoteURL, repo.RepoName(), checkoutBranch); err != nil { return err } - } else if _, err := opts.GitClient.Clone(context.Background(), remoteURL, []string{}); err != nil { + } else if _, err := git.RunClone(remoteURL, []string{}); err != nil { return err } } @@ -430,7 +427,6 @@ func createFromLocal(opts *CreateOptions) error { } repoPath := opts.Source - opts.GitClient.RepoDir = repoPath var baseRemote string if opts.Remote == "" { @@ -444,7 +440,7 @@ func createFromLocal(opts *CreateOptions) error { return err } - isRepo, err := isLocalRepo(opts.GitClient) + isRepo, err := isLocalRepo(repoPath) if err != nil { return err } @@ -455,7 +451,7 @@ func createFromLocal(opts *CreateOptions) error { return fmt.Errorf("%s is not a git repository. Run `git -C \"%s\" init` to initialize it", absPath, repoPath) } - committed, err := hasCommits(opts.GitClient) + committed, err := hasCommits(repoPath) if err != nil { return err } @@ -537,7 +533,7 @@ func createFromLocal(opts *CreateOptions) error { } } - if err := sourceInit(opts.GitClient, opts.IO, remoteURL, baseRemote); err != nil { + if err := sourceInit(opts.IO, remoteURL, baseRemote, repoPath); err != nil { return err } @@ -551,7 +547,7 @@ func createFromLocal(opts *CreateOptions) error { } if opts.Push { - repoPush, err := opts.GitClient.Command(context.Background(), "push", "-u", baseRemote, "HEAD") + repoPush, err := git.GitCommand("-C", repoPath, "push", "-u", baseRemote, "HEAD") if err != nil { return err } @@ -567,17 +563,17 @@ func createFromLocal(opts *CreateOptions) error { return nil } -func sourceInit(gitClient *git.Client, io *iostreams.IOStreams, remoteURL, baseRemote string) error { +func sourceInit(io *iostreams.IOStreams, remoteURL, baseRemote, repoPath string) error { cs := io.ColorScheme() isTTY := io.IsStdoutTTY() stdout := io.Out - remoteAdd, err := gitClient.Command(context.Background(), "remote", "add", baseRemote, remoteURL) + remoteAdd, err := git.GitCommand("-C", repoPath, "remote", "add", baseRemote, remoteURL) if err != nil { return err } - _, err = remoteAdd.Output() + err = remoteAdd.Run() if err != nil { return fmt.Errorf("%s Unable to add remote %q", cs.FailureIcon(), baseRemote) } @@ -588,12 +584,12 @@ func sourceInit(gitClient *git.Client, io *iostreams.IOStreams, remoteURL, baseR } // check if local repository has committed changes -func hasCommits(gitClient *git.Client) (bool, error) { - hasCommitsCmd, err := gitClient.Command(context.Background(), "rev-parse", "HEAD") +func hasCommits(repoPath string) (bool, error) { + hasCommitsCmd, err := git.GitCommand("-C", repoPath, "rev-parse", "HEAD") if err != nil { return false, err } - _, err = hasCommitsCmd.Output() + err = hasCommitsCmd.Run() if err == nil { return true, nil } @@ -610,8 +606,8 @@ func hasCommits(gitClient *git.Client) (bool, error) { } // check if path is the top level directory of a git repo -func isLocalRepo(gitClient *git.Client) (bool, error) { - projectDir, projectDirErr := gitClient.GitDir(context.Background()) +func isLocalRepo(repoPath string) (bool, error) { + projectDir, projectDirErr := git.GetDirFromPath(repoPath) if projectDirErr != nil { var execError *exec.ExitError if errors.As(projectDirErr, &execError) { @@ -628,26 +624,28 @@ func isLocalRepo(gitClient *git.Client) (bool, error) { } // clone the checkout branch to specified path -func localInit(gitClient *git.Client, remoteURL, path, checkoutBranch string) error { - ctx := context.Background() - gitInit, err := gitClient.Command(ctx, "init", path) +func localInit(io *iostreams.IOStreams, remoteURL, path, checkoutBranch string) error { + gitInit, err := git.GitCommand("init", path) if err != nil { return err } - _, err = gitInit.Output() + isTTY := io.IsStdoutTTY() + if isTTY { + gitInit.Stdout = io.Out + } + gitInit.Stderr = io.ErrOut + err = gitInit.Run() if err != nil { return err } - // Clone the client so we do not modify the original client's RepoDir. - gc := cloneGitClient(gitClient) - gc.RepoDir = path - - gitRemoteAdd, err := gc.Command(ctx, "remote", "add", "origin", remoteURL) + gitRemoteAdd, err := git.GitCommand("-C", path, "remote", "add", "origin", remoteURL) if err != nil { return err } - _, err = gitRemoteAdd.Output() + gitRemoteAdd.Stdout = io.Out + gitRemoteAdd.Stderr = io.ErrOut + err = gitRemoteAdd.Run() if err != nil { return err } @@ -656,21 +654,24 @@ func localInit(gitClient *git.Client, remoteURL, path, checkoutBranch string) er return nil } - gitFetch, err := gc.Command(ctx, "fetch", "origin", fmt.Sprintf("+refs/heads/%[1]s:refs/remotes/origin/%[1]s", checkoutBranch)) + gitFetch, err := git.GitCommand("-C", path, "fetch", "origin", fmt.Sprintf("+refs/heads/%[1]s:refs/remotes/origin/%[1]s", checkoutBranch)) if err != nil { return err } + gitFetch.Stdout = io.Out + gitFetch.Stderr = io.ErrOut err = gitFetch.Run() if err != nil { return err } - gitCheckout, err := gc.Command(ctx, "checkout", checkoutBranch) + gitCheckout, err := git.GitCommand("-C", path, "checkout", checkoutBranch) if err != nil { return err } - _, err = gitCheckout.Output() - return err + gitCheckout.Stdout = io.Out + gitCheckout.Stderr = io.ErrOut + return gitCheckout.Run() } func interactiveGitIgnore(client *http.Client, hostname string, prompter iprompter) (string, error) { @@ -735,14 +736,3 @@ func interactiveRepoInfo(prompter iprompter, defaultName string) (string, string return name, description, strings.ToUpper(visibilityOptions[selected]), nil } - -func cloneGitClient(c *git.Client) *git.Client { - return &git.Client{ - GhPath: c.GhPath, - RepoDir: c.RepoDir, - GitPath: c.GitPath, - Stderr: c.Stderr, - Stdin: c.Stdin, - Stdout: c.Stdout, - } -} diff --git a/pkg/cmd/repo/create/create_test.go b/pkg/cmd/repo/create/create_test.go index a1ce875f0..f8849afd1 100644 --- a/pkg/cmd/repo/create/create_test.go +++ b/pkg/cmd/repo/create/create_test.go @@ -6,7 +6,6 @@ import ( "net/http" "testing" - "github.com/cli/cli/v2/git" "github.com/cli/cli/v2/internal/config" "github.com/cli/cli/v2/internal/prompter" "github.com/cli/cli/v2/internal/run" @@ -493,8 +492,6 @@ func Test_createRun(t *testing.T) { return config.NewBlankConfig(), nil } - tt.opts.GitClient = &git.Client{GitPath: "some/path/git"} - ios, _, stdout, stderr := iostreams.Test() ios.SetStdinTTY(tt.tty) ios.SetStdoutTTY(tt.tty) diff --git a/pkg/cmd/repo/fork/fork.go b/pkg/cmd/repo/fork/fork.go index e17e4b34d..9416ba4b2 100644 --- a/pkg/cmd/repo/fork/fork.go +++ b/pkg/cmd/repo/fork/fork.go @@ -1,7 +1,6 @@ package fork import ( - "context" "fmt" "net/http" "net/url" @@ -10,7 +9,7 @@ import ( "github.com/MakeNowJust/heredoc" "github.com/cli/cli/v2/api" - ghContext "github.com/cli/cli/v2/context" + "github.com/cli/cli/v2/context" "github.com/cli/cli/v2/git" "github.com/cli/cli/v2/internal/config" "github.com/cli/cli/v2/internal/ghrepo" @@ -26,11 +25,10 @@ const defaultRemoteName = "origin" type ForkOptions struct { HttpClient func() (*http.Client, error) - GitClient *git.Client Config func() (config.Config, error) IO *iostreams.IOStreams BaseRepo func() (ghrepo.Interface, error) - Remotes func() (ghContext.Remotes, error) + Remotes func() (context.Remotes, error) Since func(time.Time) time.Duration GitArgs []string @@ -53,7 +51,6 @@ func NewCmdFork(f *cmdutil.Factory, runF func(*ForkOptions) error) *cobra.Comman opts := &ForkOptions{ IO: f.IOStreams, HttpClient: f.HttpClient, - GitClient: f.GitClient, Config: f.Config, BaseRepo: f.BaseRepo, Remotes: f.Remotes, @@ -229,9 +226,6 @@ func forkRun(opts *ForkOptions) error { } protocol, _ := cfg.Get(repoToFork.RepoHost(), "git_protocol") - gitClient := opts.GitClient - ctx := context.Background() - if inParent { remotes, err := opts.Remotes() if err != nil { @@ -270,7 +264,6 @@ func forkRun(opts *ForkOptions) error { return fmt.Errorf("failed to prompt: %w", err) } } - if remoteDesired { remoteName := opts.RemoteName remotes, err := opts.Remotes() @@ -281,11 +274,11 @@ func forkRun(opts *ForkOptions) error { if _, err := remotes.FindByName(remoteName); err == nil { if opts.Rename { renameTarget := "upstream" - renameCmd, err := gitClient.Command(ctx, "remote", "rename", remoteName, renameTarget) + renameCmd, err := git.GitCommand("remote", "rename", remoteName, renameTarget) if err != nil { return err } - _, err = renameCmd.Output() + err = renameCmd.Run() if err != nil { return err } @@ -296,7 +289,7 @@ func forkRun(opts *ForkOptions) error { forkedRepoCloneURL := ghrepo.FormatRemoteURL(forkedRepo, protocol) - _, err = gitClient.AddRemote(ctx, remoteName, forkedRepoCloneURL, []string{}) + _, err = git.AddRemote(remoteName, forkedRepoCloneURL) if err != nil { return fmt.Errorf("failed to add remote: %w", err) } @@ -316,13 +309,13 @@ func forkRun(opts *ForkOptions) error { } if cloneDesired { forkedRepoURL := ghrepo.FormatRemoteURL(forkedRepo, protocol) - cloneDir, err := gitClient.Clone(ctx, forkedRepoURL, opts.GitArgs) + cloneDir, err := git.RunClone(forkedRepoURL, opts.GitArgs) if err != nil { return fmt.Errorf("failed to clone fork: %w", err) } upstreamURL := ghrepo.FormatRemoteURL(repoToFork, protocol) - _, err = gitClient.AddRemote(ctx, "upstream", upstreamURL, []string{}, git.WithRepoDir(cloneDir)) + err = git.AddNamedRemote(upstreamURL, "upstream", cloneDir, []string{}) if err != nil { return err } diff --git a/pkg/cmd/repo/fork/fork_test.go b/pkg/cmd/repo/fork/fork_test.go index e5ffda36b..111eb98f6 100644 --- a/pkg/cmd/repo/fork/fork_test.go +++ b/pkg/cmd/repo/fork/fork_test.go @@ -700,8 +700,6 @@ func TestRepoFork(t *testing.T) { return tt.remotes, nil } - tt.opts.GitClient = &git.Client{GitPath: "some/path/git"} - //nolint:staticcheck // SA1019: prompt.InitAskStubber is deprecated: use NewAskStubber as, teardown := prompt.InitAskStubber() defer teardown() diff --git a/pkg/cmd/repo/rename/rename.go b/pkg/cmd/repo/rename/rename.go index 2d07cd8b0..0b256d1d3 100644 --- a/pkg/cmd/repo/rename/rename.go +++ b/pkg/cmd/repo/rename/rename.go @@ -1,14 +1,13 @@ package rename import ( - "context" "fmt" "net/http" "github.com/AlecAivazis/survey/v2" "github.com/MakeNowJust/heredoc" "github.com/cli/cli/v2/api" - ghContext "github.com/cli/cli/v2/context" + "github.com/cli/cli/v2/context" "github.com/cli/cli/v2/git" "github.com/cli/cli/v2/internal/config" "github.com/cli/cli/v2/internal/ghrepo" @@ -20,11 +19,10 @@ import ( type RenameOptions struct { HttpClient func() (*http.Client, error) - GitClient *git.Client IO *iostreams.IOStreams Config func() (config.Config, error) BaseRepo func() (ghrepo.Interface, error) - Remotes func() (ghContext.Remotes, error) + Remotes func() (context.Remotes, error) DoConfirm bool HasRepoOverride bool newRepoSelector string @@ -34,7 +32,6 @@ func NewCmdRename(f *cmdutil.Factory, runf func(*RenameOptions) error) *cobra.Co opts := &RenameOptions{ IO: f.IOStreams, HttpClient: f.HttpClient, - GitClient: f.GitClient, Remotes: f.Remotes, Config: f.Config, } @@ -148,7 +145,7 @@ func renameRun(opts *RenameOptions) error { return nil } -func updateRemote(repo ghrepo.Interface, renamed ghrepo.Interface, opts *RenameOptions) (*ghContext.Remote, error) { +func updateRemote(repo ghrepo.Interface, renamed ghrepo.Interface, opts *RenameOptions) (*context.Remote, error) { cfg, err := opts.Config() if err != nil { return nil, err @@ -170,7 +167,6 @@ func updateRemote(repo ghrepo.Interface, renamed ghrepo.Interface, opts *RenameO } remoteURL := ghrepo.FormatRemoteURL(renamed, protocol) - err = opts.GitClient.UpdateRemoteURL(context.Background(), remote.Name, remoteURL) - + err = git.UpdateRemoteURL(remote.Name, remoteURL) return remote, err } diff --git a/pkg/cmd/repo/rename/rename_test.go b/pkg/cmd/repo/rename/rename_test.go index 523b2ba4a..97e1aebb7 100644 --- a/pkg/cmd/repo/rename/rename_test.go +++ b/pkg/cmd/repo/rename/rename_test.go @@ -259,8 +259,6 @@ func TestRenameRun(t *testing.T) { ios.SetStdoutTTY(tt.tty) tt.opts.IO = ios - tt.opts.GitClient = &git.Client{GitPath: "some/path/git"} - t.Run(tt.name, func(t *testing.T) { defer reg.Verify(t) err := renameRun(&tt.opts) diff --git a/pkg/cmd/repo/sync/git.go b/pkg/cmd/repo/sync/git.go index fbb63a2ec..1ec86b053 100644 --- a/pkg/cmd/repo/sync/git.go +++ b/pkg/cmd/repo/sync/git.go @@ -1,11 +1,11 @@ package sync import ( - "context" "fmt" "strings" "github.com/cli/cli/v2/git" + "github.com/cli/cli/v2/pkg/iostreams" ) type gitClient interface { @@ -22,12 +22,12 @@ type gitClient interface { } type gitExecuter struct { - client *git.Client + io *iostreams.IOStreams } func (g *gitExecuter) BranchRemote(branch string) (string, error) { args := []string{"rev-parse", "--symbolic-full-name", "--abbrev-ref", fmt.Sprintf("%s@{u}", branch)} - cmd, err := g.client.Command(context.Background(), args...) + cmd, err := git.GitCommand(args...) if err != nil { return "", err } @@ -40,60 +40,60 @@ func (g *gitExecuter) BranchRemote(branch string) (string, error) { } func (g *gitExecuter) UpdateBranch(branch, ref string) error { - cmd, err := g.client.Command(context.Background(), "update-ref", fmt.Sprintf("refs/heads/%s", branch), ref) - if err != nil { - return err - } - _, err = cmd.Output() - return err -} - -func (g *gitExecuter) CreateBranch(branch, ref, upstream string) error { - ctx := context.Background() - cmd, err := g.client.Command(ctx, "branch", branch, ref) - if err != nil { - return err - } - if _, err := cmd.Output(); err != nil { - return err - } - cmd, err = g.client.Command(ctx, "branch", "--set-upstream-to", upstream, branch) - if err != nil { - return err - } - _, err = cmd.Output() - return err -} - -func (g *gitExecuter) CurrentBranch() (string, error) { - return g.client.CurrentBranch(context.Background()) -} - -func (g *gitExecuter) Fetch(remote, ref string) error { - args := []string{"fetch", "-q", remote, ref} - cmd, err := g.client.Command(context.Background(), args...) + cmd, err := git.GitCommand("update-ref", fmt.Sprintf("refs/heads/%s", branch), ref) if err != nil { return err } return cmd.Run() } +func (g *gitExecuter) CreateBranch(branch, ref, upstream string) error { + cmd, err := git.GitCommand("branch", branch, ref) + if err != nil { + return err + } + if err := cmd.Run(); err != nil { + return err + } + cmd, err = git.GitCommand("branch", "--set-upstream-to", upstream, branch) + if err != nil { + return err + } + return cmd.Run() +} + +func (g *gitExecuter) CurrentBranch() (string, error) { + return git.CurrentBranch() +} + +func (g *gitExecuter) Fetch(remote, ref string) error { + args := []string{"fetch", "-q", remote, ref} + cmd, err := git.GitCommand(args...) + if err != nil { + return err + } + cmd.Stdin = g.io.In + cmd.Stdout = g.io.Out + cmd.Stderr = g.io.ErrOut + return cmd.Run() +} + func (g *gitExecuter) HasLocalBranch(branch string) bool { - return g.client.HasLocalBranch(context.Background(), branch) + return git.HasLocalBranch(branch) } func (g *gitExecuter) IsAncestor(ancestor, progeny string) (bool, error) { args := []string{"merge-base", "--is-ancestor", ancestor, progeny} - cmd, err := g.client.Command(context.Background(), args...) + cmd, err := git.GitCommand(args...) if err != nil { return false, err } - _, err = cmd.Output() + err = cmd.Run() return err == nil, nil } func (g *gitExecuter) IsDirty() (bool, error) { - cmd, err := g.client.Command(context.Background(), "status", "--untracked-files=no", "--porcelain") + cmd, err := git.GitCommand("status", "--untracked-files=no", "--porcelain") if err != nil { return false, err } @@ -109,20 +109,18 @@ func (g *gitExecuter) IsDirty() (bool, error) { func (g *gitExecuter) MergeFastForward(ref string) error { args := []string{"merge", "--ff-only", "--quiet", ref} - cmd, err := g.client.Command(context.Background(), args...) + cmd, err := git.GitCommand(args...) if err != nil { return err } - _, err = cmd.Output() - return err + return cmd.Run() } func (g *gitExecuter) ResetHard(ref string) error { args := []string{"reset", "--hard", ref} - cmd, err := g.client.Command(context.Background(), args...) + cmd, err := git.GitCommand(args...) if err != nil { return err } - _, err = cmd.Output() - return err + return cmd.Run() } diff --git a/pkg/cmd/repo/sync/sync.go b/pkg/cmd/repo/sync/sync.go index eebec4389..ba2fdbafb 100644 --- a/pkg/cmd/repo/sync/sync.go +++ b/pkg/cmd/repo/sync/sync.go @@ -39,7 +39,7 @@ func NewCmdSync(f *cmdutil.Factory, runF func(*SyncOptions) error) *cobra.Comman IO: f.IOStreams, BaseRepo: f.BaseRepo, Remotes: f.Remotes, - Git: &gitExecuter{client: f.GitClient}, + Git: &gitExecuter{io: f.IOStreams}, } cmd := &cobra.Command{ From 4294ee14a1a80ca820988c2497961da4f6646e9e Mon Sep 17 00:00:00 2001 From: Sam Coe Date: Thu, 20 Oct 2022 15:17:20 +0300 Subject: [PATCH 43/44] revert revert 57fbe4f317ca7d0849eeeedb16c1abc21a81913b (#6474) --- context/context.go | 4 +- git/client.go | 183 +++++++++++++++------ git/client_test.go | 2 +- git/git.go | 154 ----------------- internal/run/run.go | 4 + pkg/cmd/auth/login/login.go | 4 + pkg/cmd/auth/login/login_test.go | 3 + pkg/cmd/auth/refresh/refresh.go | 10 +- pkg/cmd/auth/refresh/refresh_test.go | 2 +- pkg/cmd/auth/setupgit/setupgit.go | 1 + pkg/cmd/auth/shared/git_credential.go | 30 ++-- pkg/cmd/auth/shared/git_credential_test.go | 4 + pkg/cmd/auth/shared/login_flow.go | 8 +- pkg/cmd/browse/browse.go | 21 ++- pkg/cmd/browse/browse_test.go | 2 +- pkg/cmd/extension/manager.go | 4 +- pkg/cmd/factory/default.go | 23 +-- pkg/cmd/gist/clone/clone.go | 5 +- pkg/cmd/gist/clone/clone_test.go | 2 + pkg/cmd/pr/checkout/checkout.go | 65 ++++---- pkg/cmd/pr/checkout/checkout_test.go | 3 + pkg/cmd/pr/close/close.go | 10 +- pkg/cmd/pr/close/close_test.go | 2 + pkg/cmd/pr/create/create.go | 49 +++--- pkg/cmd/pr/create/create_test.go | 4 +- pkg/cmd/pr/merge/merge.go | 25 +-- pkg/cmd/pr/merge/merge_test.go | 1 + pkg/cmd/pr/shared/finder.go | 14 +- pkg/cmd/pr/shared/survey.go | 17 -- pkg/cmd/pr/shared/templates.go | 4 +- pkg/cmd/pr/status/status.go | 13 +- pkg/cmd/pr/status/status_test.go | 4 +- pkg/cmd/release/create/create.go | 23 +-- pkg/cmd/release/create/create_test.go | 5 + pkg/cmd/repo/clone/clone.go | 9 +- pkg/cmd/repo/clone/clone_test.go | 2 + pkg/cmd/repo/create/create.go | 76 +++++---- pkg/cmd/repo/create/create_test.go | 3 + pkg/cmd/repo/fork/fork.go | 21 ++- pkg/cmd/repo/fork/fork_test.go | 2 + pkg/cmd/repo/rename/rename.go | 12 +- pkg/cmd/repo/rename/rename_test.go | 2 + pkg/cmd/repo/sync/git.go | 46 +++--- pkg/cmd/repo/sync/sync.go | 2 +- 44 files changed, 458 insertions(+), 422 deletions(-) delete mode 100644 git/git.go diff --git a/context/context.go b/context/context.go index 45dfcfd97..568e3e623 100644 --- a/context/context.go +++ b/context/context.go @@ -2,6 +2,7 @@ package context import ( + "context" "errors" "sort" @@ -138,7 +139,8 @@ func (r *ResolvedRemotes) BaseRepo(io *iostreams.IOStreams, p iprompter) (ghrepo } // cache the result to git config - err := git.SetRemoteResolution(remote.Name, resolution) + c := &git.Client{} + err := c.SetRemoteResolution(context.Background(), remote.Name, resolution) return selectedRepo, err } diff --git a/git/client.go b/git/client.go index bada86265..2cf6e01df 100644 --- a/git/client.go +++ b/git/client.go @@ -38,22 +38,16 @@ func (e *NotInstalled) Unwrap() error { } type GitError struct { - stderr string - err error + ExitCode int + Stderr string + err error } func (ge *GitError) Error() string { - stderr := ge.stderr - if stderr == "" { - var exitError *exec.ExitError - if errors.As(ge.err, &exitError) { - stderr = string(exitError.Stderr) - } - } - if stderr == "" { + if ge.Stderr == "" { return fmt.Sprintf("failed to run git: %v", ge.err) } - return fmt.Sprintf("failed to run git: %s", stderr) + return fmt.Sprintf("failed to run git: %s", ge.Stderr) } func (ge *GitError) Unwrap() error { @@ -64,16 +58,77 @@ type gitCommand struct { *exec.Cmd } -// This is a hack in order to not break the hundreds of -// existing tests that rely on `run.PrepareCmd` to be invoked. func (gc *gitCommand) Run() error { - return run.PrepareCmd(gc.Cmd).Run() + // This is a hack in order to not break the hundreds of + // existing tests that rely on `run.PrepareCmd` to be invoked. + err := run.PrepareCmd(gc.Cmd).Run() + if err != nil { + ge := GitError{err: err} + var exitError *exec.ExitError + if errors.As(err, &exitError) { + ge.Stderr = string(exitError.Stderr) + ge.ExitCode = exitError.ExitCode() + } + return &ge + } + return nil } -// This is a hack in order to not break the hundreds of -// existing tests that rely on `run.PrepareCmd` to be invoked. func (gc *gitCommand) Output() ([]byte, error) { - return run.PrepareCmd(gc.Cmd).Output() + gc.Stdout = nil + gc.Stderr = nil + // This is a hack in order to not break the hundreds of + // existing tests that rely on `run.PrepareCmd` to be invoked. + out, err := run.PrepareCmd(gc.Cmd).Output() + if err != nil { + ge := GitError{err: err} + var exitError *exec.ExitError + if errors.As(err, &exitError) { + ge.Stderr = string(exitError.Stderr) + ge.ExitCode = exitError.ExitCode() + } + err = &ge + } + return out, err +} + +func (gc *gitCommand) setRepoDir(repoDir string) { + for i, arg := range gc.Args { + if arg == "-C" { + gc.Args[i+1] = repoDir + return + } + } + gc.Args = append(gc.Args[:3], gc.Args[1:]...) + gc.Args[1] = "-C" + gc.Args[2] = repoDir +} + +// Allow individual commands to be modified from the default client options. +type CommandModifier func(*gitCommand) + +func WithStderr(stderr io.Writer) CommandModifier { + return func(gc *gitCommand) { + gc.Stderr = stderr + } +} + +func WithStdout(stdout io.Writer) CommandModifier { + return func(gc *gitCommand) { + gc.Stdout = stdout + } +} + +func WithStdin(stdin io.Reader) CommandModifier { + return func(gc *gitCommand) { + gc.Stdin = stdin + } +} + +func WithRepoDir(repoDir string) CommandModifier { + return func(gc *gitCommand) { + gc.setRepoDir(repoDir) + } } type Client struct { @@ -133,8 +188,7 @@ func resolveGitPath() (string, error) { // AuthenticatedCommand is a wrapper around Command that included configuration to use gh // as the credential helper for git. func (c *Client) AuthenticatedCommand(ctx context.Context, args ...string) (*gitCommand, error) { - preArgs := []string{} - preArgs = append(preArgs, "-c", "credential.helper=") + preArgs := []string{"-c", "credential.helper="} if c.GhPath == "" { // Assumes that gh is in PATH. c.GhPath = "gh" @@ -153,7 +207,7 @@ func (c *Client) Remotes(ctx context.Context) (RemoteSet, error) { } remoteOut, remoteErr := remoteCmd.Output() if remoteErr != nil { - return nil, &GitError{err: remoteErr} + return nil, remoteErr } configArgs := []string{"config", "--get-regexp", `^remote\..*\.gh-resolved$`} @@ -164,9 +218,9 @@ func (c *Client) Remotes(ctx context.Context) (RemoteSet, error) { configOut, configErr := configCmd.Output() if configErr != nil { // Ignore exit code 1 as it means there are no resolved remotes. - var exitErr *exec.ExitError - if errors.As(configErr, &exitErr) && exitErr.ExitCode() != 1 { - return nil, &GitError{err: configErr} + var gitErr *GitError + if ok := errors.As(configErr, &gitErr); ok && gitErr.ExitCode != 1 { + return nil, gitErr } } @@ -176,18 +230,20 @@ func (c *Client) Remotes(ctx context.Context) (RemoteSet, error) { return remotes, nil } -func (c *Client) AddRemote(ctx context.Context, name, urlStr string, trackingBranches []string) (*Remote, error) { +func (c *Client) AddRemote(ctx context.Context, name, urlStr string, trackingBranches []string, mods ...CommandModifier) (*Remote, error) { args := []string{"remote", "add"} for _, branch := range trackingBranches { args = append(args, "-t", branch) } args = append(args, "-f", name, urlStr) - //TODO: Use AuthenticatedCommand cmd, err := c.Command(ctx, args...) if err != nil { return nil, err } - if err := cmd.Run(); err != nil { + for _, mod := range mods { + mod(cmd) + } + if _, err := cmd.Output(); err != nil { return nil, err } var urlParsed *url.URL @@ -216,7 +272,11 @@ func (c *Client) UpdateRemoteURL(ctx context.Context, name, url string) error { if err != nil { return err } - return cmd.Run() + _, err = cmd.Output() + if err != nil { + return err + } + return nil } func (c *Client) SetRemoteResolution(ctx context.Context, name, resolution string) error { @@ -225,7 +285,11 @@ func (c *Client) SetRemoteResolution(ctx context.Context, name, resolution strin if err != nil { return err } - return cmd.Run() + _, err = cmd.Output() + if err != nil { + return err + } + return nil } // CurrentBranch reads the checked-out branch for the git repository. @@ -235,14 +299,14 @@ func (c *Client) CurrentBranch(ctx context.Context) (string, error) { if err != nil { return "", err } - errBuf := bytes.Buffer{} - cmd.Stderr = &errBuf out, err := cmd.Output() if err != nil { - if errBuf.Len() == 0 { - return "", &GitError{err: err, stderr: "not on any branch"} + var gitErr *GitError + if ok := errors.As(err, &gitErr); ok && len(gitErr.Stderr) == 0 { + gitErr.Stderr = "not on any branch" + return "", gitErr } - return "", &GitError{err: err, stderr: errBuf.String()} + return "", err } branch := firstLine(out) return strings.TrimPrefix(branch, "refs/heads/"), nil @@ -278,15 +342,14 @@ func (c *Client) Config(ctx context.Context, name string) (string, error) { if err != nil { return "", err } - errBuf := bytes.Buffer{} - cmd.Stderr = &errBuf out, err := cmd.Output() if err != nil { - var exitError *exec.ExitError - if ok := errors.As(err, &exitError); ok && exitError.Error() == "1" { - return "", &GitError{err: err, stderr: fmt.Sprintf("unknown config key %s", name)} + var gitErr *GitError + if ok := errors.As(err, &gitErr); ok && gitErr.ExitCode == 1 { + gitErr.Stderr = fmt.Sprintf("unknown config key %s", name) + return "", gitErr } - return "", &GitError{err: err, stderr: errBuf.String()} + return "", err } return firstLine(out), nil } @@ -299,7 +362,7 @@ func (c *Client) UncommittedChangeCount(ctx context.Context) (int, error) { } out, err := cmd.Output() if err != nil { - return 0, &GitError{err: err} + return 0, err } lines := strings.Split(string(out), "\n") count := 0 @@ -319,7 +382,7 @@ func (c *Client) Commits(ctx context.Context, baseRef, headRef string) ([]*Commi } out, err := cmd.Output() if err != nil { - return nil, &GitError{err: err} + return nil, err } commits := []*Commit{} sha := 0 @@ -348,7 +411,7 @@ func (c *Client) lookupCommit(ctx context.Context, sha, format string) ([]byte, } out, err := cmd.Output() if err != nil { - return nil, &GitError{err: err} + return nil, err } return out, nil } @@ -371,13 +434,16 @@ func (c *Client) CommitBody(ctx context.Context, sha string) (string, error) { } // Push publishes a git ref to a remote and sets up upstream configuration. -func (c *Client) Push(ctx context.Context, remote string, ref string) error { +func (c *Client) Push(ctx context.Context, remote string, ref string, mods ...CommandModifier) error { args := []string{"push", "--set-upstream", remote, ref} //TODO: Use AuthenticatedCommand cmd, err := c.Command(ctx, args...) if err != nil { return err } + for _, mod := range mods { + mod(cmd) + } return cmd.Run() } @@ -423,7 +489,11 @@ func (c *Client) DeleteLocalBranch(ctx context.Context, branch string) error { if err != nil { return err } - return cmd.Run() + _, err = cmd.Output() + if err != nil { + return err + } + return nil } func (c *Client) HasLocalBranch(ctx context.Context, branch string) bool { @@ -432,7 +502,7 @@ func (c *Client) HasLocalBranch(ctx context.Context, branch string) bool { if err != nil { return false } - err = cmd.Run() + _, err = cmd.Output() return err == nil } @@ -442,7 +512,11 @@ func (c *Client) CheckoutBranch(ctx context.Context, branch string) error { if err != nil { return err } - return cmd.Run() + _, err = cmd.Output() + if err != nil { + return err + } + return nil } func (c *Client) CheckoutNewBranch(ctx context.Context, remoteName, branch string) error { @@ -452,7 +526,11 @@ func (c *Client) CheckoutNewBranch(ctx context.Context, remoteName, branch strin if err != nil { return err } - return cmd.Run() + _, err = cmd.Output() + if err != nil { + return err + } + return nil } func (c *Client) Pull(ctx context.Context, remote, branch string) error { @@ -465,7 +543,7 @@ func (c *Client) Pull(ctx context.Context, remote, branch string) error { return cmd.Run() } -func (c *Client) Clone(ctx context.Context, cloneURL string, args []string) (target string, err error) { +func (c *Client) Clone(ctx context.Context, cloneURL string, args []string) (string, error) { cloneArgs, target := parseCloneArgs(args) cloneArgs = append(cloneArgs, cloneURL) // If the args contain an explicit target, pass it to clone @@ -482,7 +560,10 @@ func (c *Client) Clone(ctx context.Context, cloneURL string, args []string) (tar return "", err } err = cmd.Run() - return + if err != nil { + return "", err + } + return target, nil } // ToplevelDir returns the top-level directory path of the current repository. @@ -494,7 +575,7 @@ func (c *Client) ToplevelDir(ctx context.Context) (string, error) { } out, err := cmd.Output() if err != nil { - return "", &GitError{err: err} + return "", err } return firstLine(out), nil } @@ -507,7 +588,7 @@ func (c *Client) GitDir(ctx context.Context) (string, error) { } out, err := cmd.Output() if err != nil { - return "", &GitError{err: err} + return "", err } return firstLine(out), nil } diff --git a/git/client_test.go b/git/client_test.go index 121231d23..cff47358f 100644 --- a/git/client_test.go +++ b/git/client_test.go @@ -394,6 +394,6 @@ func initRepo(t *testing.T, dir string) { } cmd, err := client.Command(context.Background(), []string{"init", "--quiet"}...) assert.NoError(t, err) - err = cmd.Run() + _, err = cmd.Output() assert.NoError(t, err) } diff --git a/git/git.go b/git/git.go deleted file mode 100644 index 5b934175b..000000000 --- a/git/git.go +++ /dev/null @@ -1,154 +0,0 @@ -package git - -import ( - "context" - "io" - "os" -) - -func GitCommand(args ...string) (*gitCommand, error) { - c := &Client{} - return c.Command(context.Background(), args...) -} - -func ShowRefs(ref ...string) ([]Ref, error) { - c := &Client{} - return c.ShowRefs(context.Background(), ref...) -} - -func CurrentBranch() (string, error) { - c := &Client{} - return c.CurrentBranch(context.Background()) -} - -func Config(name string) (string, error) { - c := &Client{} - return c.Config(context.Background(), name) -} - -func UncommittedChangeCount() (int, error) { - c := &Client{} - return c.UncommittedChangeCount(context.Background()) -} - -func Commits(baseRef, headRef string) ([]*Commit, error) { - c := &Client{} - return c.Commits(context.Background(), baseRef, headRef) -} - -func LastCommit() (*Commit, error) { - c := &Client{} - return c.LastCommit(context.Background()) -} - -func CommitBody(sha string) (string, error) { - c := &Client{} - return c.CommitBody(context.Background(), sha) -} - -func Push(remote string, ref string, cmdIn io.ReadCloser, cmdOut, cmdErr io.Writer) error { - //TODO: Replace with factory GitClient and use AuthenticatedCommand - c := &Client{ - Stdin: cmdIn, - Stdout: cmdOut, - Stderr: cmdErr, - } - return c.Push(context.Background(), remote, ref) -} - -func ReadBranchConfig(branch string) (cfg BranchConfig) { - c := &Client{} - return c.ReadBranchConfig(context.Background(), branch) -} - -func DeleteLocalBranch(branch string) error { - c := &Client{} - return c.DeleteLocalBranch(context.Background(), branch) -} - -func HasLocalBranch(branch string) bool { - c := &Client{} - return c.HasLocalBranch(context.Background(), branch) -} - -func CheckoutBranch(branch string) error { - c := &Client{} - return c.CheckoutBranch(context.Background(), branch) -} - -func CheckoutNewBranch(remoteName, branch string) error { - c := &Client{} - return c.CheckoutNewBranch(context.Background(), remoteName, branch) -} - -func Pull(remote, branch string) error { - //TODO: Replace with factory GitClient and use AuthenticatedCommand - c := &Client{ - Stdin: os.Stdin, - Stdout: os.Stdout, - Stderr: os.Stderr, - } - return c.Pull(context.Background(), remote, branch) -} - -func RunClone(cloneURL string, args []string) (target string, err error) { - //TODO: Replace with factory GitClient and use AuthenticatedCommand - c := &Client{ - Stdin: os.Stdin, - Stdout: os.Stdout, - Stderr: os.Stderr, - } - return c.Clone(context.Background(), cloneURL, args) -} - -func ToplevelDir() (string, error) { - c := &Client{} - return c.ToplevelDir(context.Background()) -} - -func GetDirFromPath(repoDir string) (string, error) { - c := &Client{ - RepoDir: repoDir, - } - return c.GitDir(context.Background()) -} - -func PathFromRepoRoot() string { - c := &Client{} - return c.PathFromRoot(context.Background()) -} - -func Remotes() (RemoteSet, error) { - c := &Client{} - return c.Remotes(context.Background()) -} - -func RemotesForPath(repoDir string) (RemoteSet, error) { - c := &Client{ - RepoDir: repoDir, - } - return c.Remotes(context.Background()) -} - -func AddRemote(name, url string) (*Remote, error) { - c := &Client{} - return c.AddRemote(context.Background(), name, url, []string{}) -} - -func AddNamedRemote(url, name, repoDir string, branches []string) error { - c := &Client{ - RepoDir: repoDir, - } - _, err := c.AddRemote(context.Background(), name, url, branches) - return err -} - -func UpdateRemoteURL(name, url string) error { - c := &Client{} - return c.UpdateRemoteURL(context.Background(), name, url) -} - -func SetRemoteResolution(name, resolution string) error { - c := &Client{} - return c.SetRemoteResolution(context.Background(), name, resolution) -} diff --git a/internal/run/run.go b/internal/run/run.go index d482e04cc..a7260db14 100644 --- a/internal/run/run.go +++ b/internal/run/run.go @@ -76,6 +76,10 @@ func (e CmdError) Error() string { return fmt.Sprintf("%s%s: %s", msg, e.Args[0], e.Err) } +func (e CmdError) Unwrap() error { + return e.Err +} + func printArgs(w io.Writer, args []string) error { if len(args) > 0 { // print commands, but omit the full path to an executable diff --git a/pkg/cmd/auth/login/login.go b/pkg/cmd/auth/login/login.go index be12392d1..364abb9f0 100644 --- a/pkg/cmd/auth/login/login.go +++ b/pkg/cmd/auth/login/login.go @@ -7,6 +7,7 @@ import ( "strings" "github.com/MakeNowJust/heredoc" + "github.com/cli/cli/v2/git" "github.com/cli/cli/v2/internal/config" "github.com/cli/cli/v2/internal/ghinstance" "github.com/cli/cli/v2/pkg/cmd/auth/shared" @@ -20,6 +21,7 @@ type LoginOptions struct { IO *iostreams.IOStreams Config func() (config.Config, error) HttpClient func() (*http.Client, error) + GitClient *git.Client Prompter shared.Prompt MainExecutable string @@ -38,6 +40,7 @@ func NewCmdLogin(f *cmdutil.Factory, runF func(*LoginOptions) error) *cobra.Comm IO: f.IOStreams, Config: f.Config, HttpClient: f.HttpClient, + GitClient: f.GitClient, Prompter: f.Prompter, } @@ -183,6 +186,7 @@ func loginRun(opts *LoginOptions) error { Executable: opts.MainExecutable, GitProtocol: opts.GitProtocol, Prompter: opts.Prompter, + GitClient: opts.GitClient, }) } diff --git a/pkg/cmd/auth/login/login_test.go b/pkg/cmd/auth/login/login_test.go index 649fdb48d..85e133b2b 100644 --- a/pkg/cmd/auth/login/login_test.go +++ b/pkg/cmd/auth/login/login_test.go @@ -8,6 +8,7 @@ import ( "testing" "github.com/MakeNowJust/heredoc" + "github.com/cli/cli/v2/git" "github.com/cli/cli/v2/internal/config" "github.com/cli/cli/v2/internal/prompter" "github.com/cli/cli/v2/internal/run" @@ -597,6 +598,8 @@ func Test_loginRun_Survey(t *testing.T) { } tt.opts.Prompter = pm + tt.opts.GitClient = &git.Client{GitPath: "some/path/git"} + rs, restoreRun := run.Stub() defer restoreRun(t) if tt.runStubs != nil { diff --git a/pkg/cmd/auth/refresh/refresh.go b/pkg/cmd/auth/refresh/refresh.go index 7b1142e7e..c66fb5237 100644 --- a/pkg/cmd/auth/refresh/refresh.go +++ b/pkg/cmd/auth/refresh/refresh.go @@ -6,6 +6,7 @@ import ( "strings" "github.com/MakeNowJust/heredoc" + "github.com/cli/cli/v2/git" "github.com/cli/cli/v2/internal/authflow" "github.com/cli/cli/v2/internal/config" "github.com/cli/cli/v2/pkg/cmd/auth/shared" @@ -17,7 +18,8 @@ import ( type RefreshOptions struct { IO *iostreams.IOStreams Config func() (config.Config, error) - httpClient *http.Client + HttpClient *http.Client + GitClient *git.Client Prompter shared.Prompt MainExecutable string @@ -37,7 +39,8 @@ func NewCmdRefresh(f *cmdutil.Factory, runF func(*RefreshOptions) error) *cobra. _, err := authflow.AuthFlowWithConfig(cfg, io, hostname, "", scopes, interactive) return err }, - httpClient: &http.Client{}, + HttpClient: &http.Client{}, + GitClient: f.GitClient, Prompter: f.Prompter, } @@ -122,7 +125,7 @@ func refreshRun(opts *RefreshOptions) error { var additionalScopes []string if oldToken, _ := cfg.AuthToken(hostname); oldToken != "" { - if oldScopes, err := shared.GetScopes(opts.httpClient, hostname, oldToken); err == nil { + if oldScopes, err := shared.GetScopes(opts.HttpClient, hostname, oldToken); err == nil { for _, s := range strings.Split(oldScopes, ",") { s = strings.TrimSpace(s) if s != "" { @@ -135,6 +138,7 @@ func refreshRun(opts *RefreshOptions) error { credentialFlow := &shared.GitCredentialFlow{ Executable: opts.MainExecutable, Prompter: opts.Prompter, + GitClient: opts.GitClient, } gitProtocol, _ := cfg.GetOrDefault(hostname, "git_protocol") if opts.Interactive && gitProtocol == "https" { diff --git a/pkg/cmd/auth/refresh/refresh_test.go b/pkg/cmd/auth/refresh/refresh_test.go index ffacb22d9..d1d6042c0 100644 --- a/pkg/cmd/auth/refresh/refresh_test.go +++ b/pkg/cmd/auth/refresh/refresh_test.go @@ -272,7 +272,7 @@ func Test_refreshRun(t *testing.T) { }, nil }, ) - tt.opts.httpClient = &http.Client{Transport: httpReg} + tt.opts.HttpClient = &http.Client{Transport: httpReg} pm := &prompter.PrompterMock{} if tt.prompterStubs != nil { diff --git a/pkg/cmd/auth/setupgit/setupgit.go b/pkg/cmd/auth/setupgit/setupgit.go index b5caefe5b..edc531235 100644 --- a/pkg/cmd/auth/setupgit/setupgit.go +++ b/pkg/cmd/auth/setupgit/setupgit.go @@ -34,6 +34,7 @@ func NewCmdSetupGit(f *cmdutil.Factory, runF func(*SetupGitOptions) error) *cobr RunE: func(cmd *cobra.Command, args []string) error { opts.gitConfigure = &shared.GitCredentialFlow{ Executable: f.Executable(), + GitClient: f.GitClient, } if runF != nil { diff --git a/pkg/cmd/auth/shared/git_credential.go b/pkg/cmd/auth/shared/git_credential.go index 491e2c82f..5ddac14d5 100644 --- a/pkg/cmd/auth/shared/git_credential.go +++ b/pkg/cmd/auth/shared/git_credential.go @@ -2,6 +2,7 @@ package shared import ( "bytes" + "context" "errors" "fmt" "path/filepath" @@ -16,6 +17,7 @@ import ( type GitCredentialFlow struct { Executable string Prompter Prompt + GitClient *git.Client shouldSetup bool helper string @@ -24,7 +26,7 @@ type GitCredentialFlow struct { func (flow *GitCredentialFlow) Prompt(hostname string) error { var gitErr error - flow.helper, gitErr = gitCredentialHelper(hostname) + flow.helper, gitErr = gitCredentialHelper(flow.GitClient, hostname) if isOurCredentialHelper(flow.helper) { flow.scopes = append(flow.scopes, "workflow") return nil @@ -59,6 +61,9 @@ func (flow *GitCredentialFlow) Setup(hostname, username, authToken string) error } func (flow *GitCredentialFlow) gitCredentialSetup(hostname, username, password string) error { + gitClient := flow.GitClient + ctx := context.Background() + if flow.helper == "" { credHelperKeys := []string{ gitCredentialHelperKey(hostname), @@ -76,18 +81,18 @@ func (flow *GitCredentialFlow) gitCredentialSetup(hostname, username, password s break } // first use a blank value to indicate to git we want to sever the chain of credential helpers - preConfigureCmd, err := git.GitCommand("config", "--global", "--replace-all", credHelperKey, "") + preConfigureCmd, err := gitClient.Command(ctx, "config", "--global", "--replace-all", credHelperKey, "") if err != nil { configErr = err break } - if err = preConfigureCmd.Run(); err != nil { + if _, err = preConfigureCmd.Output(); err != nil { configErr = err break } // second configure the actual helper for this host - configureCmd, err := git.GitCommand( + configureCmd, err := gitClient.Command(ctx, "config", "--global", "--add", credHelperKey, fmt.Sprintf("!%s auth git-credential", shellQuote(flow.Executable)), @@ -95,7 +100,7 @@ func (flow *GitCredentialFlow) gitCredentialSetup(hostname, username, password s if err != nil { configErr = err } else { - configErr = configureCmd.Run() + _, configErr = configureCmd.Output() } } @@ -103,7 +108,7 @@ func (flow *GitCredentialFlow) gitCredentialSetup(hostname, username, password s } // clear previous cached credentials - rejectCmd, err := git.GitCommand("credential", "reject") + rejectCmd, err := gitClient.Command(ctx, "credential", "reject") if err != nil { return err } @@ -113,12 +118,12 @@ func (flow *GitCredentialFlow) gitCredentialSetup(hostname, username, password s host=%s `, hostname)) - err = rejectCmd.Run() + _, err = rejectCmd.Output() if err != nil { return err } - approveCmd, err := git.GitCommand("credential", "approve") + approveCmd, err := gitClient.Command(ctx, "credential", "approve") if err != nil { return err } @@ -130,7 +135,7 @@ func (flow *GitCredentialFlow) gitCredentialSetup(hostname, username, password s password=%s `, hostname, username, password)) - err = approveCmd.Run() + _, err = approveCmd.Output() if err != nil { return err } @@ -143,12 +148,13 @@ func gitCredentialHelperKey(hostname string) string { return fmt.Sprintf("credential.%s.helper", host) } -func gitCredentialHelper(hostname string) (helper string, err error) { - helper, err = git.Config(gitCredentialHelperKey(hostname)) +func gitCredentialHelper(gitClient *git.Client, hostname string) (helper string, err error) { + ctx := context.Background() + helper, err = gitClient.Config(ctx, gitCredentialHelperKey(hostname)) if helper != "" { return } - helper, err = git.Config("credential.helper") + helper, err = gitClient.Config(ctx, "credential.helper") return } diff --git a/pkg/cmd/auth/shared/git_credential_test.go b/pkg/cmd/auth/shared/git_credential_test.go index fe674e1d7..9d3e90cc4 100644 --- a/pkg/cmd/auth/shared/git_credential_test.go +++ b/pkg/cmd/auth/shared/git_credential_test.go @@ -3,6 +3,7 @@ package shared import ( "testing" + "github.com/cli/cli/v2/git" "github.com/cli/cli/v2/internal/run" ) @@ -15,6 +16,7 @@ func TestGitCredentialSetup_configureExisting(t *testing.T) { f := GitCredentialFlow{ Executable: "gh", helper: "osxkeychain", + GitClient: &git.Client{GitPath: "some/path/git"}, } if err := f.gitCredentialSetup("example.com", "monalisa", "PASSWD"); err != nil { @@ -61,6 +63,7 @@ func TestGitCredentialsSetup_setOurs_GH(t *testing.T) { f := GitCredentialFlow{ Executable: "/path/to/gh", helper: "", + GitClient: &git.Client{GitPath: "some/path/git"}, } if err := f.gitCredentialSetup("github.com", "monalisa", "PASSWD"); err != nil { @@ -92,6 +95,7 @@ func TestGitCredentialSetup_setOurs_nonGH(t *testing.T) { f := GitCredentialFlow{ Executable: "/path/to/gh", helper: "", + GitClient: &git.Client{GitPath: "some/path/git"}, } if err := f.gitCredentialSetup("example.com", "monalisa", "PASSWD"); err != nil { diff --git a/pkg/cmd/auth/shared/login_flow.go b/pkg/cmd/auth/shared/login_flow.go index 9cc9d9865..99d82b5f7 100644 --- a/pkg/cmd/auth/shared/login_flow.go +++ b/pkg/cmd/auth/shared/login_flow.go @@ -8,6 +8,7 @@ import ( "github.com/MakeNowJust/heredoc" "github.com/cli/cli/v2/api" + "github.com/cli/cli/v2/git" "github.com/cli/cli/v2/internal/authflow" "github.com/cli/cli/v2/internal/ghinstance" "github.com/cli/cli/v2/pkg/cmd/ssh-key/add" @@ -27,6 +28,7 @@ type LoginOptions struct { IO *iostreams.IOStreams Config iconfig HTTPClient *http.Client + GitClient *git.Client Hostname string Interactive bool Web bool @@ -63,7 +65,11 @@ func Login(opts *LoginOptions) error { var additionalScopes []string - credentialFlow := &GitCredentialFlow{Executable: opts.Executable, Prompter: opts.Prompter} + credentialFlow := &GitCredentialFlow{ + Executable: opts.Executable, + Prompter: opts.Prompter, + GitClient: opts.GitClient, + } if opts.Interactive && gitProtocol == "https" { if err := credentialFlow.Prompt(hostname); err != nil { return err diff --git a/pkg/cmd/browse/browse.go b/pkg/cmd/browse/browse.go index ddff8bd92..85572fdac 100644 --- a/pkg/cmd/browse/browse.go +++ b/pkg/cmd/browse/browse.go @@ -1,6 +1,7 @@ package browse import ( + "context" "fmt" "net/http" "net/url" @@ -41,11 +42,13 @@ type BrowseOptions struct { func NewCmdBrowse(f *cmdutil.Factory, runF func(*BrowseOptions) error) *cobra.Command { opts := &BrowseOptions{ - Browser: f.Browser, - HttpClient: f.HttpClient, - IO: f.IOStreams, - PathFromRepoRoot: git.PathFromRepoRoot, - GitClient: &localGitClient{}, + Browser: f.Browser, + HttpClient: f.HttpClient, + IO: f.IOStreams, + PathFromRepoRoot: func() string { + return f.GitClient.PathFromRoot(context.Background()) + }, + GitClient: &localGitClient{client: f.GitClient}, } cmd := &cobra.Command{ @@ -269,14 +272,18 @@ type gitClient interface { LastCommit() (*git.Commit, error) } -type localGitClient struct{} +type localGitClient struct { + client *git.Client +} type remoteGitClient struct { repo func() (ghrepo.Interface, error) httpClient func() (*http.Client, error) } -func (gc *localGitClient) LastCommit() (*git.Commit, error) { return git.LastCommit() } +func (gc *localGitClient) LastCommit() (*git.Commit, error) { + return gc.client.LastCommit(context.Background()) +} func (gc *remoteGitClient) LastCommit() (*git.Commit, error) { httpClient, err := gc.httpClient() diff --git a/pkg/cmd/browse/browse_test.go b/pkg/cmd/browse/browse_test.go index 91197cc96..75e615be0 100644 --- a/pkg/cmd/browse/browse_test.go +++ b/pkg/cmd/browse/browse_test.go @@ -466,7 +466,7 @@ func Test_runBrowse(t *testing.T) { } opts.Browser = &browser if opts.PathFromRepoRoot == nil { - opts.PathFromRepoRoot = git.PathFromRepoRoot + opts.PathFromRepoRoot = func() string { return "" } } err := runBrowse(&opts) diff --git a/pkg/cmd/extension/manager.go b/pkg/cmd/extension/manager.go index 289a9aefa..c9f638b87 100644 --- a/pkg/cmd/extension/manager.go +++ b/pkg/cmd/extension/manager.go @@ -2,6 +2,7 @@ package extension import ( "bytes" + "context" _ "embed" "errors" "fmt" @@ -789,7 +790,8 @@ func isBinExtension(client *http.Client, repo ghrepo.Interface) (isBin bool, err } func repoFromPath(path string) (ghrepo.Interface, error) { - remotes, err := git.RemotesForPath(path) + gitClient := &git.Client{RepoDir: path} + remotes, err := gitClient.Remotes(context.Background()) if err != nil { return nil, err } diff --git a/pkg/cmd/factory/default.go b/pkg/cmd/factory/default.go index 77552c977..10bf5d72b 100644 --- a/pkg/cmd/factory/default.go +++ b/pkg/cmd/factory/default.go @@ -1,6 +1,7 @@ package factory import ( + "context" "fmt" "net/http" "os" @@ -8,7 +9,7 @@ import ( "time" "github.com/cli/cli/v2/api" - "github.com/cli/cli/v2/context" + ghContext "github.com/cli/cli/v2/context" "github.com/cli/cli/v2/git" "github.com/cli/cli/v2/internal/browser" "github.com/cli/cli/v2/internal/config" @@ -25,18 +26,18 @@ var ssoURLRE = regexp.MustCompile(`\burl=([^;]+)`) func New(appVersion string) *cmdutil.Factory { f := &cmdutil.Factory{ Config: configFunc(), // No factory dependencies - Branch: branchFunc(), // No factory dependencies ExecutableName: "gh", } f.IOStreams = ioStreams(f) // Depends on Config f.HttpClient = httpClientFunc(f, appVersion) // Depends on Config, IOStreams, and appVersion - f.Remotes = remotesFunc(f) // Depends on Config + f.GitClient = newGitClient(f) // Depends on IOStreams, and Executable + f.Remotes = remotesFunc(f) // Depends on Config, and GitClient f.BaseRepo = BaseRepoFunc(f) // Depends on Remotes f.Prompter = newPrompter(f) // Depends on Config and IOStreams f.Browser = newBrowser(f) // Depends on Config, and IOStreams - f.GitClient = newGitClient(f) // Depends on IOStreams, and Executable f.ExtensionManager = extensionManager(f) // Depends on Config, HttpClient, and IOStreams + f.Branch = branchFunc(f) // Depends on GitClient return f } @@ -64,7 +65,7 @@ func SmartBaseRepoFunc(f *cmdutil.Factory) func() (ghrepo.Interface, error) { if err != nil { return nil, err } - repoContext, err := context.ResolveRemotesToRepos(remotes, apiClient, "") + repoContext, err := ghContext.ResolveRemotesToRepos(remotes, apiClient, "") if err != nil { return nil, err } @@ -77,10 +78,12 @@ func SmartBaseRepoFunc(f *cmdutil.Factory) func() (ghrepo.Interface, error) { } } -func remotesFunc(f *cmdutil.Factory) func() (context.Remotes, error) { +func remotesFunc(f *cmdutil.Factory) func() (ghContext.Remotes, error) { rr := &remoteResolver{ - readRemotes: git.Remotes, - getConfig: f.Config, + readRemotes: func() (git.RemoteSet, error) { + return f.GitClient.Remotes(context.Background()) + }, + getConfig: f.Config, } return rr.Resolver() } @@ -142,9 +145,9 @@ func configFunc() func() (config.Config, error) { } } -func branchFunc() func() (string, error) { +func branchFunc(f *cmdutil.Factory) func() (string, error) { return func() (string, error) { - currentBranch, err := git.CurrentBranch() + currentBranch, err := f.GitClient.CurrentBranch(context.Background()) if err != nil { return "", fmt.Errorf("could not determine current branch: %w", err) } diff --git a/pkg/cmd/gist/clone/clone.go b/pkg/cmd/gist/clone/clone.go index 41fa104fa..ed074e700 100644 --- a/pkg/cmd/gist/clone/clone.go +++ b/pkg/cmd/gist/clone/clone.go @@ -1,6 +1,7 @@ package clone import ( + "context" "fmt" "net/http" @@ -16,6 +17,7 @@ import ( type CloneOptions struct { HttpClient func() (*http.Client, error) + GitClient *git.Client Config func() (config.Config, error) IO *iostreams.IOStreams @@ -28,6 +30,7 @@ func NewCmdClone(f *cmdutil.Factory, runF func(*CloneOptions) error) *cobra.Comm opts := &CloneOptions{ IO: f.IOStreams, HttpClient: f.HttpClient, + GitClient: f.GitClient, Config: f.Config, } @@ -84,7 +87,7 @@ func cloneRun(opts *CloneOptions) error { gistURL = formatRemoteURL(hostname, gistURL, protocol) } - _, err := git.RunClone(gistURL, opts.GitArgs) + _, err := opts.GitClient.Clone(context.Background(), gistURL, opts.GitArgs) if err != nil { return err } diff --git a/pkg/cmd/gist/clone/clone_test.go b/pkg/cmd/gist/clone/clone_test.go index ccca76b25..cd6f71b6e 100644 --- a/pkg/cmd/gist/clone/clone_test.go +++ b/pkg/cmd/gist/clone/clone_test.go @@ -5,6 +5,7 @@ import ( "strings" "testing" + "github.com/cli/cli/v2/git" "github.com/cli/cli/v2/internal/config" "github.com/cli/cli/v2/internal/run" "github.com/cli/cli/v2/pkg/cmdutil" @@ -25,6 +26,7 @@ func runCloneCommand(httpClient *http.Client, cli string) (*test.CmdOut, error) Config: func() (config.Config, error) { return config.NewBlankConfig(), nil }, + GitClient: &git.Client{GitPath: "some/path/git"}, } cmd := NewCmdClone(fac, nil) diff --git a/pkg/cmd/pr/checkout/checkout.go b/pkg/cmd/pr/checkout/checkout.go index 73c8f40d1..8addfc4ae 100644 --- a/pkg/cmd/pr/checkout/checkout.go +++ b/pkg/cmd/pr/checkout/checkout.go @@ -19,6 +19,7 @@ import ( type CheckoutOptions struct { HttpClient func() (*http.Client, error) + GitClient *git.Client Config func() (config.Config, error) IO *iostreams.IOStreams Remotes func() (cliContext.Remotes, error) @@ -37,6 +38,7 @@ func NewCmdCheckout(f *cmdutil.Factory, runF func(*CheckoutOptions) error) *cobr opts := &CheckoutOptions{ IO: f.IOStreams, HttpClient: f.HttpClient, + GitClient: f.GitClient, Config: f.Config, Remotes: f.Remotes, Branch: f.Branch, @@ -124,11 +126,11 @@ func checkoutRun(opts *CheckoutOptions) error { } if opts.RecurseSubmodules { - cmdQueue = append(cmdQueue, []string{"git", "submodule", "sync", "--recursive"}) - cmdQueue = append(cmdQueue, []string{"git", "submodule", "update", "--init", "--recursive"}) + cmdQueue = append(cmdQueue, []string{"submodule", "sync", "--recursive"}) + cmdQueue = append(cmdQueue, []string{"submodule", "update", "--init", "--recursive"}) } - err = executeCmds(cmdQueue, opts.IO) + err = executeCmds(opts.GitClient, cmdQueue) if err != nil { return err } @@ -145,7 +147,7 @@ func cmdsForExistingRemote(remote *cliContext.Remote, pr *api.PullRequest, opts refSpec += fmt.Sprintf(":refs/remotes/%s", remoteBranch) } - cmds = append(cmds, []string{"git", "fetch", remote.Name, refSpec}) + cmds = append(cmds, []string{"fetch", remote.Name, refSpec}) localBranch := pr.HeadRefName if opts.BranchName != "" { @@ -154,17 +156,17 @@ func cmdsForExistingRemote(remote *cliContext.Remote, pr *api.PullRequest, opts switch { case opts.Detach: - cmds = append(cmds, []string{"git", "checkout", "--detach", "FETCH_HEAD"}) - case localBranchExists(localBranch): - cmds = append(cmds, []string{"git", "checkout", localBranch}) + cmds = append(cmds, []string{"checkout", "--detach", "FETCH_HEAD"}) + case localBranchExists(opts.GitClient, localBranch): + cmds = append(cmds, []string{"checkout", localBranch}) if opts.Force { - cmds = append(cmds, []string{"git", "reset", "--hard", fmt.Sprintf("refs/remotes/%s", remoteBranch)}) + cmds = append(cmds, []string{"reset", "--hard", fmt.Sprintf("refs/remotes/%s", remoteBranch)}) } else { // TODO: check if non-fast-forward and suggest to use `--force` - cmds = append(cmds, []string{"git", "merge", "--ff-only", fmt.Sprintf("refs/remotes/%s", remoteBranch)}) + cmds = append(cmds, []string{"merge", "--ff-only", fmt.Sprintf("refs/remotes/%s", remoteBranch)}) } default: - cmds = append(cmds, []string{"git", "checkout", "-b", localBranch, "--track", remoteBranch}) + cmds = append(cmds, []string{"checkout", "-b", localBranch, "--track", remoteBranch}) } return cmds @@ -175,8 +177,8 @@ func cmdsForMissingRemote(pr *api.PullRequest, baseURLOrName, repoHost, defaultB ref := fmt.Sprintf("refs/pull/%d/head", pr.Number) if opts.Detach { - cmds = append(cmds, []string{"git", "fetch", baseURLOrName, ref}) - cmds = append(cmds, []string{"git", "checkout", "--detach", "FETCH_HEAD"}) + cmds = append(cmds, []string{"fetch", baseURLOrName, ref}) + cmds = append(cmds, []string{"checkout", "--detach", "FETCH_HEAD"}) return cmds } @@ -191,22 +193,22 @@ func cmdsForMissingRemote(pr *api.PullRequest, baseURLOrName, repoHost, defaultB currentBranch, _ := opts.Branch() if localBranch == currentBranch { // PR head matches currently checked out branch - cmds = append(cmds, []string{"git", "fetch", baseURLOrName, ref}) + cmds = append(cmds, []string{"fetch", baseURLOrName, ref}) if opts.Force { - cmds = append(cmds, []string{"git", "reset", "--hard", "FETCH_HEAD"}) + cmds = append(cmds, []string{"reset", "--hard", "FETCH_HEAD"}) } else { // TODO: check if non-fast-forward and suggest to use `--force` - cmds = append(cmds, []string{"git", "merge", "--ff-only", "FETCH_HEAD"}) + cmds = append(cmds, []string{"merge", "--ff-only", "FETCH_HEAD"}) } } else { if opts.Force { - cmds = append(cmds, []string{"git", "fetch", baseURLOrName, fmt.Sprintf("%s:%s", ref, localBranch), "--force"}) + cmds = append(cmds, []string{"fetch", baseURLOrName, fmt.Sprintf("%s:%s", ref, localBranch), "--force"}) } else { // TODO: check if non-fast-forward and suggest to use `--force` - cmds = append(cmds, []string{"git", "fetch", baseURLOrName, fmt.Sprintf("%s:%s", ref, localBranch)}) + cmds = append(cmds, []string{"fetch", baseURLOrName, fmt.Sprintf("%s:%s", ref, localBranch)}) } - cmds = append(cmds, []string{"git", "checkout", localBranch}) + cmds = append(cmds, []string{"checkout", localBranch}) } remote := baseURLOrName @@ -216,37 +218,32 @@ func cmdsForMissingRemote(pr *api.PullRequest, baseURLOrName, repoHost, defaultB remote = ghrepo.FormatRemoteURL(headRepo, protocol) mergeRef = fmt.Sprintf("refs/heads/%s", pr.HeadRefName) } - if missingMergeConfigForBranch(localBranch) { + if missingMergeConfigForBranch(opts.GitClient, localBranch) { // .remote is needed for `git pull` to work // .pushRemote is needed for `git push` to work, if user has set `remote.pushDefault`. // see https://git-scm.com/docs/git-config#Documentation/git-config.txt-branchltnamegtremote - cmds = append(cmds, []string{"git", "config", fmt.Sprintf("branch.%s.remote", localBranch), remote}) - cmds = append(cmds, []string{"git", "config", fmt.Sprintf("branch.%s.pushRemote", localBranch), remote}) - cmds = append(cmds, []string{"git", "config", fmt.Sprintf("branch.%s.merge", localBranch), mergeRef}) + cmds = append(cmds, []string{"config", fmt.Sprintf("branch.%s.remote", localBranch), remote}) + cmds = append(cmds, []string{"config", fmt.Sprintf("branch.%s.pushRemote", localBranch), remote}) + cmds = append(cmds, []string{"config", fmt.Sprintf("branch.%s.merge", localBranch), mergeRef}) } return cmds } -func missingMergeConfigForBranch(b string) bool { - mc, err := git.Config(fmt.Sprintf("branch.%s.merge", b)) +func missingMergeConfigForBranch(client *git.Client, b string) bool { + mc, err := client.Config(context.Background(), fmt.Sprintf("branch.%s.merge", b)) return err != nil || mc == "" } -func localBranchExists(b string) bool { - _, err := git.ShowRefs("refs/heads/" + b) +func localBranchExists(client *git.Client, b string) bool { + _, err := client.ShowRefs(context.Background(), "refs/heads/"+b) return err == nil } -func executeCmds(cmdQueue [][]string, ios *iostreams.IOStreams) error { - //TODO: Replace with factory GitClient - //TODO: Use AuthenticatedCommand - client := git.Client{ - Stdout: ios.Out, - Stderr: ios.ErrOut, - } +func executeCmds(client *git.Client, cmdQueue [][]string) error { for _, args := range cmdQueue { - cmd, err := client.Command(context.Background(), args[1:]...) + //TODO: Use AuthenticatedCommand + cmd, err := client.Command(context.Background(), args...) if err != nil { return err } diff --git a/pkg/cmd/pr/checkout/checkout_test.go b/pkg/cmd/pr/checkout/checkout_test.go index df77e6676..7540bbacf 100644 --- a/pkg/cmd/pr/checkout/checkout_test.go +++ b/pkg/cmd/pr/checkout/checkout_test.go @@ -197,6 +197,8 @@ func Test_checkoutRun(t *testing.T) { return remotes, nil } + opts.GitClient = &git.Client{GitPath: "some/path/git"} + err := checkoutRun(opts) if (err != nil) != tt.wantErr { t.Errorf("want error: %v, got: %v", tt.wantErr, err) @@ -234,6 +236,7 @@ func runCommand(rt http.RoundTripper, remotes context.Remotes, branch string, cl Branch: func() (string, error) { return branch, nil }, + GitClient: &git.Client{GitPath: "some/path/git"}, } cmd := NewCmdCheckout(factory, nil) diff --git a/pkg/cmd/pr/close/close.go b/pkg/cmd/pr/close/close.go index 7d80199b6..7e39005bd 100644 --- a/pkg/cmd/pr/close/close.go +++ b/pkg/cmd/pr/close/close.go @@ -1,6 +1,7 @@ package close import ( + "context" "fmt" "net/http" @@ -15,6 +16,7 @@ import ( type CloseOptions struct { HttpClient func() (*http.Client, error) + GitClient *git.Client IO *iostreams.IOStreams Branch func() (string, error) @@ -30,6 +32,7 @@ func NewCmdClose(f *cmdutil.Factory, runF func(*CloseOptions) error) *cobra.Comm opts := &CloseOptions{ IO: f.IOStreams, HttpClient: f.HttpClient, + GitClient: f.GitClient, Branch: f.Branch, } @@ -108,9 +111,10 @@ func closeRun(opts *CloseOptions) error { fmt.Fprintf(opts.IO.ErrOut, "%s Closed pull request #%d (%s)\n", cs.SuccessIconWithColor(cs.Red), pr.Number, pr.Title) if opts.DeleteBranch { + ctx := context.Background() branchSwitchString := "" apiClient := api.NewClientFromHTTP(httpClient) - localBranchExists := git.HasLocalBranch(pr.HeadRefName) + localBranchExists := opts.GitClient.HasLocalBranch(ctx, pr.HeadRefName) if opts.DeleteLocalBranch { if localBranchExists { @@ -125,13 +129,13 @@ func closeRun(opts *CloseOptions) error { if err != nil { return err } - err = git.CheckoutBranch(branchToSwitchTo) + err = opts.GitClient.CheckoutBranch(ctx, branchToSwitchTo) if err != nil { return err } } - if err := git.DeleteLocalBranch(pr.HeadRefName); err != nil { + if err := opts.GitClient.DeleteLocalBranch(ctx, pr.HeadRefName); err != nil { return fmt.Errorf("failed to delete local branch %s: %w", cs.Cyan(pr.HeadRefName), err) } diff --git a/pkg/cmd/pr/close/close_test.go b/pkg/cmd/pr/close/close_test.go index df9585fbb..b435fb8aa 100644 --- a/pkg/cmd/pr/close/close_test.go +++ b/pkg/cmd/pr/close/close_test.go @@ -9,6 +9,7 @@ import ( "github.com/MakeNowJust/heredoc" "github.com/cli/cli/v2/api" + "github.com/cli/cli/v2/git" "github.com/cli/cli/v2/internal/ghrepo" "github.com/cli/cli/v2/internal/run" "github.com/cli/cli/v2/pkg/cmd/pr/shared" @@ -72,6 +73,7 @@ func runCommand(rt http.RoundTripper, isTTY bool, cli string) (*test.CmdOut, err Branch: func() (string, error) { return "trunk", nil }, + GitClient: &git.Client{GitPath: "some/path/git"}, } cmd := NewCmdClose(factory, nil) diff --git a/pkg/cmd/pr/create/create.go b/pkg/cmd/pr/create/create.go index e9c664b88..75d4b58f1 100644 --- a/pkg/cmd/pr/create/create.go +++ b/pkg/cmd/pr/create/create.go @@ -1,6 +1,7 @@ package create import ( + "context" "errors" "fmt" "net/http" @@ -12,7 +13,7 @@ import ( "github.com/AlecAivazis/survey/v2" "github.com/MakeNowJust/heredoc" "github.com/cli/cli/v2/api" - "github.com/cli/cli/v2/context" + ghContext "github.com/cli/cli/v2/context" "github.com/cli/cli/v2/git" "github.com/cli/cli/v2/internal/browser" "github.com/cli/cli/v2/internal/config" @@ -32,9 +33,10 @@ type iprompter interface { type CreateOptions struct { // This struct stores user input and factory functions HttpClient func() (*http.Client, error) + GitClient *git.Client Config func() (config.Config, error) IO *iostreams.IOStreams - Remotes func() (context.Remotes, error) + Remotes func() (ghContext.Remotes, error) Branch func() (string, error) Browser browser.Browser Prompter iprompter @@ -68,22 +70,24 @@ type CreateOptions struct { type CreateContext struct { // This struct stores contextual data about the creation process and is for building up enough // data to create a pull request - RepoContext *context.ResolvedRemotes + RepoContext *ghContext.ResolvedRemotes BaseRepo *api.Repository HeadRepo ghrepo.Interface BaseTrackingBranch string BaseBranch string HeadBranch string HeadBranchLabel string - HeadRemote *context.Remote + HeadRemote *ghContext.Remote IsPushEnabled bool Client *api.Client + GitClient *git.Client } func NewCmdCreate(f *cmdutil.Factory, runF func(*CreateOptions) error) *cobra.Command { opts := &CreateOptions{ IO: f.IOStreams, HttpClient: f.HttpClient, + GitClient: f.GitClient, Config: f.Config, Remotes: f.Remotes, Branch: f.Branch, @@ -369,15 +373,16 @@ func createRun(opts *CreateOptions) (err error) { func initDefaultTitleBody(ctx CreateContext, state *shared.IssueMetadataState) error { baseRef := ctx.BaseTrackingBranch headRef := ctx.HeadBranch + gitClient := ctx.GitClient - commits, err := git.Commits(baseRef, headRef) + commits, err := gitClient.Commits(context.Background(), baseRef, headRef) if err != nil { return err } if len(commits) == 1 { state.Title = commits[0].Title - body, err := git.CommitBody(commits[0].Sha) + body, err := gitClient.CommitBody(context.Background(), commits[0].Sha) if err != nil { return err } @@ -395,11 +400,11 @@ func initDefaultTitleBody(ctx CreateContext, state *shared.IssueMetadataState) e return nil } -func determineTrackingBranch(remotes context.Remotes, headBranch string) *git.TrackingRef { +func determineTrackingBranch(gitClient *git.Client, remotes ghContext.Remotes, headBranch string) *git.TrackingRef { refsForLookup := []string{"HEAD"} var trackingRefs []git.TrackingRef - headBranchConfig := git.ReadBranchConfig(headBranch) + headBranchConfig := gitClient.ReadBranchConfig(context.Background(), headBranch) if headBranchConfig.RemoteName != "" { tr := git.TrackingRef{ RemoteName: headBranchConfig.RemoteName, @@ -418,7 +423,7 @@ func determineTrackingBranch(remotes context.Remotes, headBranch string) *git.Tr refsForLookup = append(refsForLookup, tr.String()) } - resolvedRefs, _ := git.ShowRefs(refsForLookup...) + resolvedRefs, _ := gitClient.ShowRefs(context.Background(), refsForLookup...) if len(resolvedRefs) > 1 { for _, r := range resolvedRefs[1:] { if r.Hash != resolvedRefs[0].Hash { @@ -480,7 +485,7 @@ func NewCreateContext(opts *CreateOptions) (*CreateContext, error) { return nil, err } - repoContext, err := context.ResolveRemotesToRepos(remotes, client, opts.RepoOverride) + repoContext, err := ghContext.ResolveRemotesToRepos(remotes, client, opts.RepoOverride) if err != nil { return nil, err } @@ -515,16 +520,17 @@ func NewCreateContext(opts *CreateOptions) (*CreateContext, error) { headBranch = headBranch[idx+1:] } - if ucc, err := git.UncommittedChangeCount(); err == nil && ucc > 0 { + gitClient := opts.GitClient + if ucc, err := gitClient.UncommittedChangeCount(context.Background()); err == nil && ucc > 0 { fmt.Fprintf(opts.IO.ErrOut, "Warning: %s\n", text.Pluralize(ucc, "uncommitted change")) } var headRepo ghrepo.Interface - var headRemote *context.Remote + var headRemote *ghContext.Remote if isPushEnabled { // determine whether the head branch is already pushed to a remote - if pushedTo := determineTrackingBranch(remotes, headBranch); pushedTo != nil { + if pushedTo := determineTrackingBranch(gitClient, remotes, headBranch); pushedTo != nil { isPushEnabled = false if r, err := remotes.FindByName(pushedTo.RemoteName); err == nil { headRepo = r @@ -625,6 +631,7 @@ func NewCreateContext(opts *CreateOptions) (*CreateContext, error) { IsPushEnabled: isPushEnabled, RepoContext: repoContext, Client: client, + GitClient: gitClient, }, nil } @@ -713,11 +720,12 @@ func handlePush(opts CreateOptions, ctx CreateContext) error { headRepoURL := ghrepo.FormatRemoteURL(headRepo, cloneProtocol) // TODO: prevent clashes with another remote of a same name - gitRemote, err := git.AddRemote("fork", headRepoURL) + gitClient := ctx.GitClient + gitRemote, err := gitClient.AddRemote(context.Background(), "fork", headRepoURL, []string{}) if err != nil { return fmt.Errorf("error adding remote: %w", err) } - headRemote = &context.Remote{ + headRemote = &ghContext.Remote{ Remote: gitRemote, Repo: headRepo, } @@ -729,12 +737,11 @@ func handlePush(opts CreateOptions, ctx CreateContext) error { pushTries := 0 maxPushTries := 3 for { - r := NewRegexpWriter(opts.IO.ErrOut, gitPushRegexp, "") - defer r.Flush() - cmdErr := r - cmdIn := opts.IO.In - cmdOut := opts.IO.Out - if err := git.Push(headRemote.Name, fmt.Sprintf("HEAD:%s", ctx.HeadBranch), cmdIn, cmdOut, cmdErr); err != nil { + w := NewRegexpWriter(opts.IO.ErrOut, gitPushRegexp, "") + defer w.Flush() + gitClient := ctx.GitClient + ref := fmt.Sprintf("HEAD:%s", ctx.HeadBranch) + if err := gitClient.Push(context.Background(), headRemote.Name, ref, git.WithStderr(w)); err != nil { if didForkRepo && pushTries < maxPushTries { pushTries++ // first wait 2 seconds after forking, then 4s, then 6s diff --git a/pkg/cmd/pr/create/create_test.go b/pkg/cmd/pr/create/create_test.go index c83dd13a2..0764ffe3b 100644 --- a/pkg/cmd/pr/create/create_test.go +++ b/pkg/cmd/pr/create/create_test.go @@ -869,6 +869,7 @@ func Test_createRun(t *testing.T) { return branch, nil } opts.Finder = shared.NewMockFinder(branch, nil, nil) + opts.GitClient = &git.Client{GitPath: "some/path/git"} cleanSetup := func() {} if tt.setup != nil { cleanSetup = tt.setup(&opts, t) @@ -985,7 +986,8 @@ func Test_determineTrackingBranch(t *testing.T) { tt.cmdStubs(cs) - ref := determineTrackingBranch(tt.remotes, "feature") + gitClient := &git.Client{GitPath: "some/path/git"} + ref := determineTrackingBranch(gitClient, tt.remotes, "feature") tt.assert(ref, t) }) } diff --git a/pkg/cmd/pr/merge/merge.go b/pkg/cmd/pr/merge/merge.go index 2972c09f9..dbf0923f7 100644 --- a/pkg/cmd/pr/merge/merge.go +++ b/pkg/cmd/pr/merge/merge.go @@ -1,6 +1,7 @@ package merge import ( + "context" "errors" "fmt" "net/http" @@ -8,7 +9,7 @@ import ( "github.com/AlecAivazis/survey/v2" "github.com/MakeNowJust/heredoc" "github.com/cli/cli/v2/api" - "github.com/cli/cli/v2/context" + ghContext "github.com/cli/cli/v2/context" "github.com/cli/cli/v2/git" "github.com/cli/cli/v2/internal/config" "github.com/cli/cli/v2/internal/ghrepo" @@ -26,9 +27,10 @@ type editor interface { type MergeOptions struct { HttpClient func() (*http.Client, error) + GitClient *git.Client IO *iostreams.IOStreams Branch func() (string, error) - Remotes func() (context.Remotes, error) + Remotes func() (ghContext.Remotes, error) Finder shared.PRFinder @@ -60,6 +62,7 @@ func NewCmdMerge(f *cmdutil.Factory, runF func(*MergeOptions) error) *cobra.Comm opts := &MergeOptions{ IO: f.IOStreams, HttpClient: f.HttpClient, + GitClient: f.GitClient, Branch: f.Branch, Remotes: f.Remotes, } @@ -224,7 +227,7 @@ func (m *mergeContext) warnIfDiverged() { return } - localBranchLastCommit, err := git.LastCommit() + localBranchLastCommit, err := m.opts.GitClient.LastCommit(context.Background()) if err != nil { return } @@ -396,6 +399,8 @@ func (m *mergeContext) deleteLocalBranch() error { return err } + ctx := context.Background() + // branch the command was run on is the same as the pull request branch if currentBranch == m.pr.HeadRefName { remotes, err := m.opts.Remotes() @@ -409,24 +414,24 @@ func (m *mergeContext) deleteLocalBranch() error { } targetBranch := m.pr.BaseRefName - if git.HasLocalBranch(targetBranch) { - if err := git.CheckoutBranch(targetBranch); err != nil { + if m.opts.GitClient.HasLocalBranch(ctx, targetBranch) { + if err := m.opts.GitClient.CheckoutBranch(ctx, targetBranch); err != nil { return err } } else { - if err := git.CheckoutNewBranch(baseRemote.Name, targetBranch); err != nil { + if err := m.opts.GitClient.CheckoutNewBranch(ctx, baseRemote.Name, targetBranch); err != nil { return err } } - if err := git.Pull(baseRemote.Name, targetBranch); err != nil { + if err := m.opts.GitClient.Pull(ctx, baseRemote.Name, targetBranch); err != nil { _ = m.warnf(fmt.Sprintf("%s warning: not possible to fast-forward to: %q\n", m.cs.WarningIcon(), targetBranch)) } m.switchedToBranch = targetBranch } - if err := git.DeleteLocalBranch(m.pr.HeadRefName); err != nil { + if err := m.opts.GitClient.DeleteLocalBranch(ctx, m.pr.HeadRefName); err != nil { return fmt.Errorf("failed to delete local branch %s: %w", m.cs.Cyan(m.pr.HeadRefName), err) } @@ -503,7 +508,7 @@ func NewMergeContext(opts *MergeOptions) (*mergeContext, error) { deleteBranch: opts.DeleteBranch, crossRepoPR: pr.HeadRepositoryOwner.Login != baseRepo.RepoOwner(), autoMerge: opts.AutoMergeEnable && !isImmediatelyMergeable(pr.MergeStateStatus), - localBranchExists: opts.CanDeleteLocalBranch && git.HasLocalBranch(pr.HeadRefName), + localBranchExists: opts.CanDeleteLocalBranch && opts.GitClient.HasLocalBranch(context.Background(), pr.HeadRefName), mergeQueueRequired: pr.IsMergeQueueEnabled, }, nil } @@ -730,7 +735,7 @@ func allowsAdminOverride(status string) bool { } } -func remoteForMergeConflictResolution(baseRepo ghrepo.Interface, pr *api.PullRequest, opts *MergeOptions) *context.Remote { +func remoteForMergeConflictResolution(baseRepo ghrepo.Interface, pr *api.PullRequest, opts *MergeOptions) *ghContext.Remote { if !mergeConflictStatus(pr.MergeStateStatus) || !opts.CanDeleteLocalBranch { return nil } diff --git a/pkg/cmd/pr/merge/merge_test.go b/pkg/cmd/pr/merge/merge_test.go index d1bb21d8c..0e3ced350 100644 --- a/pkg/cmd/pr/merge/merge_test.go +++ b/pkg/cmd/pr/merge/merge_test.go @@ -270,6 +270,7 @@ func runCommand(rt http.RoundTripper, branch string, isTTY bool, cli string) (*t }, }, nil }, + GitClient: &git.Client{GitPath: "some/path/git"}, } cmd := NewCmdMerge(factory, nil) diff --git a/pkg/cmd/pr/shared/finder.go b/pkg/cmd/pr/shared/finder.go index b700a7501..ed04c4295 100644 --- a/pkg/cmd/pr/shared/finder.go +++ b/pkg/cmd/pr/shared/finder.go @@ -53,12 +53,14 @@ func NewFinder(factory *cmdutil.Factory) PRFinder { } return &finder{ - baseRepoFn: factory.BaseRepo, - branchFn: factory.Branch, - remotesFn: factory.Remotes, - httpClient: factory.HttpClient, - progress: factory.IOStreams, - branchConfig: git.ReadBranchConfig, + baseRepoFn: factory.BaseRepo, + branchFn: factory.Branch, + remotesFn: factory.Remotes, + httpClient: factory.HttpClient, + progress: factory.IOStreams, + branchConfig: func(s string) git.BranchConfig { + return factory.GitClient.ReadBranchConfig(context.Background(), s) + }, } } diff --git a/pkg/cmd/pr/shared/survey.go b/pkg/cmd/pr/shared/survey.go index 31942c6a0..dd4ba085d 100644 --- a/pkg/cmd/pr/shared/survey.go +++ b/pkg/cmd/pr/shared/survey.go @@ -6,9 +6,7 @@ import ( "github.com/AlecAivazis/survey/v2" "github.com/cli/cli/v2/api" - "github.com/cli/cli/v2/git" "github.com/cli/cli/v2/internal/ghrepo" - "github.com/cli/cli/v2/pkg/githubtemplate" "github.com/cli/cli/v2/pkg/iostreams" "github.com/cli/cli/v2/pkg/prompt" "github.com/cli/cli/v2/pkg/surveyext" @@ -369,18 +367,3 @@ func MetadataSurvey(io *iostreams.IOStreams, baseRepo ghrepo.Interface, fetcher return nil } - -func FindTemplates(dir, path string) ([]string, string) { - if dir == "" { - rootDir, err := git.ToplevelDir() - if err != nil { - return []string{}, "" - } - dir = rootDir - } - - templateFiles := githubtemplate.FindNonLegacy(dir, path) - legacyTemplate := githubtemplate.FindLegacy(dir, path) - - return templateFiles, legacyTemplate -} diff --git a/pkg/cmd/pr/shared/templates.go b/pkg/cmd/pr/shared/templates.go index 975bdd5da..7aab57978 100644 --- a/pkg/cmd/pr/shared/templates.go +++ b/pkg/cmd/pr/shared/templates.go @@ -1,6 +1,7 @@ package shared import ( + "context" "fmt" "net/http" "time" @@ -233,7 +234,8 @@ func (m *templateManager) fetch() error { dir := m.rootDir if dir == "" { var err error - dir, err = git.ToplevelDir() + gitClient := &git.Client{} + dir, err = gitClient.ToplevelDir(context.Background()) if err != nil { return nil // abort silently } diff --git a/pkg/cmd/pr/status/status.go b/pkg/cmd/pr/status/status.go index 784159564..e4434978b 100644 --- a/pkg/cmd/pr/status/status.go +++ b/pkg/cmd/pr/status/status.go @@ -1,6 +1,7 @@ package status import ( + "context" "errors" "fmt" "net/http" @@ -9,7 +10,7 @@ import ( "strings" "github.com/cli/cli/v2/api" - "github.com/cli/cli/v2/context" + ghContext "github.com/cli/cli/v2/context" "github.com/cli/cli/v2/git" "github.com/cli/cli/v2/internal/config" "github.com/cli/cli/v2/internal/ghrepo" @@ -22,10 +23,11 @@ import ( type StatusOptions struct { HttpClient func() (*http.Client, error) + GitClient *git.Client Config func() (config.Config, error) IO *iostreams.IOStreams BaseRepo func() (ghrepo.Interface, error) - Remotes func() (context.Remotes, error) + Remotes func() (ghContext.Remotes, error) Branch func() (string, error) HasRepoOverride bool @@ -37,6 +39,7 @@ func NewCmdStatus(f *cmdutil.Factory, runF func(*StatusOptions) error) *cobra.Co opts := &StatusOptions{ IO: f.IOStreams, HttpClient: f.HttpClient, + GitClient: f.GitClient, Config: f.Config, Remotes: f.Remotes, Branch: f.Branch, @@ -86,7 +89,7 @@ func statusRun(opts *StatusOptions) error { } remotes, _ := opts.Remotes() - currentPRNumber, currentPRHeadRef, err = prSelectorForCurrentBranch(baseRepo, currentBranch, remotes) + currentPRNumber, currentPRHeadRef, err = prSelectorForCurrentBranch(opts.GitClient, baseRepo, currentBranch, remotes) if err != nil { return fmt.Errorf("could not query for pull request for current branch: %w", err) } @@ -165,9 +168,9 @@ func statusRun(opts *StatusOptions) error { return nil } -func prSelectorForCurrentBranch(baseRepo ghrepo.Interface, prHeadRef string, rem context.Remotes) (prNumber int, selector string, err error) { +func prSelectorForCurrentBranch(gitClient *git.Client, baseRepo ghrepo.Interface, prHeadRef string, rem ghContext.Remotes) (prNumber int, selector string, err error) { selector = prHeadRef - branchConfig := git.ReadBranchConfig(prHeadRef) + branchConfig := gitClient.ReadBranchConfig(context.Background(), prHeadRef) // the branch is configured to merge a special PR head ref prHeadRE := regexp.MustCompile(`^refs/pull/(\d+)/head$`) diff --git a/pkg/cmd/pr/status/status_test.go b/pkg/cmd/pr/status/status_test.go index 31f28396e..c3187739d 100644 --- a/pkg/cmd/pr/status/status_test.go +++ b/pkg/cmd/pr/status/status_test.go @@ -52,6 +52,7 @@ func runCommand(rt http.RoundTripper, branch string, isTTY bool, cli string) (*t } return branch, nil }, + GitClient: &git.Client{GitPath: "some/path/git"}, } cmd := NewCmdStatus(factory, nil) @@ -328,7 +329,8 @@ func Test_prSelectorForCurrentBranch(t *testing.T) { Repo: repo, }, } - prNum, headRef, err := prSelectorForCurrentBranch(repo, "Frederick888/main", rem) + gitClient := &git.Client{GitPath: "some/path/git"} + prNum, headRef, err := prSelectorForCurrentBranch(gitClient, repo, "Frederick888/main", rem) if err != nil { t.Fatalf("prSelectorForCurrentBranch error: %v", err) } diff --git a/pkg/cmd/release/create/create.go b/pkg/cmd/release/create/create.go index e6f97dd9f..8c36218da 100644 --- a/pkg/cmd/release/create/create.go +++ b/pkg/cmd/release/create/create.go @@ -2,6 +2,7 @@ package create import ( "bytes" + "context" "errors" "fmt" "io" @@ -26,6 +27,7 @@ type CreateOptions struct { IO *iostreams.IOStreams Config func() (config.Config, error) HttpClient func() (*http.Client, error) + GitClient *git.Client BaseRepo func() (ghrepo.Interface, error) Edit func(string, string, string, io.Reader, io.Writer, io.Writer) (string, error) @@ -56,6 +58,7 @@ func NewCmdCreate(f *cmdutil.Factory, runF func(*CreateOptions) error) *cobra.Co opts := &CreateOptions{ IO: f.IOStreams, HttpClient: f.HttpClient, + GitClient: f.GitClient, Config: f.Config, Edit: surveyext.Edit, } @@ -221,7 +224,7 @@ func createRun(opts *CreateOptions) error { var tagDescription string if opts.RepoOverride == "" { - tagDescription, _ = gitTagInfo(opts.TagName) + tagDescription, _ = gitTagInfo(opts.GitClient, opts.TagName) // If there is a local tag with the same name as specified // the user may not want to create a new tag on the remote // as the local one might be annotated or signed. @@ -268,10 +271,10 @@ func createRun(opts *CreateOptions) error { } if generatedNotes == nil { if opts.NotesStartTag != "" { - commits, _ := changelogForRange(fmt.Sprintf("%s..%s", opts.NotesStartTag, headRef)) + commits, _ := changelogForRange(opts.GitClient, fmt.Sprintf("%s..%s", opts.NotesStartTag, headRef)) generatedChangelog = generateChangelog(commits) - } else if prevTag, err := detectPreviousTag(headRef); err == nil { - commits, _ := changelogForRange(fmt.Sprintf("%s..%s", prevTag, headRef)) + } else if prevTag, err := detectPreviousTag(opts.GitClient, headRef); err == nil { + commits, _ := changelogForRange(opts.GitClient, fmt.Sprintf("%s..%s", prevTag, headRef)) generatedChangelog = generateChangelog(commits) } } @@ -469,8 +472,8 @@ func createRun(opts *CreateOptions) error { return nil } -func gitTagInfo(tagName string) (string, error) { - cmd, err := git.GitCommand("tag", "--list", tagName, "--format=%(contents:subject)%0a%0a%(contents:body)") +func gitTagInfo(client *git.Client, tagName string) (string, error) { + cmd, err := client.Command(context.Background(), "tag", "--list", tagName, "--format=%(contents:subject)%0a%0a%(contents:body)") if err != nil { return "", err } @@ -478,8 +481,8 @@ func gitTagInfo(tagName string) (string, error) { return string(b), err } -func detectPreviousTag(headRef string) (string, error) { - cmd, err := git.GitCommand("describe", "--tags", "--abbrev=0", fmt.Sprintf("%s^", headRef)) +func detectPreviousTag(client *git.Client, headRef string) (string, error) { + cmd, err := client.Command(context.Background(), "describe", "--tags", "--abbrev=0", fmt.Sprintf("%s^", headRef)) if err != nil { return "", err } @@ -492,8 +495,8 @@ type logEntry struct { Body string } -func changelogForRange(refRange string) ([]logEntry, error) { - cmd, err := git.GitCommand("-c", "log.ShowSignature=false", "log", "--first-parent", "--reverse", "--pretty=format:%B%x00", refRange) +func changelogForRange(client *git.Client, refRange string) ([]logEntry, error) { + cmd, err := client.Command(context.Background(), "-c", "log.ShowSignature=false", "log", "--first-parent", "--reverse", "--pretty=format:%B%x00", refRange) if err != nil { return nil, err } diff --git a/pkg/cmd/release/create/create_test.go b/pkg/cmd/release/create/create_test.go index 7fb6711b2..a6424c0d6 100644 --- a/pkg/cmd/release/create/create_test.go +++ b/pkg/cmd/release/create/create_test.go @@ -10,6 +10,7 @@ import ( "path/filepath" "testing" + "github.com/cli/cli/v2/git" "github.com/cli/cli/v2/internal/config" "github.com/cli/cli/v2/internal/ghrepo" "github.com/cli/cli/v2/internal/run" @@ -688,6 +689,8 @@ func Test_createRun(t *testing.T) { return ghrepo.FromFullName("OWNER/REPO") } + tt.opts.GitClient = &git.Client{GitPath: "some/path/git"} + err := createRun(&tt.opts) if tt.wantErr != "" { require.EqualError(t, err, tt.wantErr) @@ -1050,6 +1053,8 @@ func Test_createRun_interactive(t *testing.T) { return val, nil } + tt.opts.GitClient = &git.Client{GitPath: "some/path/git"} + t.Run(tt.name, func(t *testing.T) { //nolint:staticcheck // SA1019: prompt.NewAskStubber is deprecated: use PrompterMock as := prompt.NewAskStubber(t) diff --git a/pkg/cmd/repo/clone/clone.go b/pkg/cmd/repo/clone/clone.go index ba289aa47..cb46d4572 100644 --- a/pkg/cmd/repo/clone/clone.go +++ b/pkg/cmd/repo/clone/clone.go @@ -1,6 +1,7 @@ package clone import ( + "context" "fmt" "net/http" "strings" @@ -18,6 +19,7 @@ import ( type CloneOptions struct { HttpClient func() (*http.Client, error) + GitClient *git.Client Config func() (config.Config, error) IO *iostreams.IOStreams @@ -30,6 +32,7 @@ func NewCmdClone(f *cmdutil.Factory, runF func(*CloneOptions) error) *cobra.Comm opts := &CloneOptions{ IO: f.IOStreams, HttpClient: f.HttpClient, + GitClient: f.GitClient, Config: f.Config, } @@ -152,7 +155,9 @@ func cloneRun(opts *CloneOptions) error { canonicalCloneURL = strings.TrimSuffix(canonicalCloneURL, ".git") + ".wiki.git" } - cloneDir, err := git.RunClone(canonicalCloneURL, opts.GitArgs) + gitClient := opts.GitClient + ctx := context.Background() + cloneDir, err := gitClient.Clone(ctx, canonicalCloneURL, opts.GitArgs) if err != nil { return err } @@ -170,7 +175,7 @@ func cloneRun(opts *CloneOptions) error { upstreamName = canonicalRepo.Parent.RepoOwner() } - err = git.AddNamedRemote(upstreamURL, upstreamName, cloneDir, []string{canonicalRepo.Parent.DefaultBranchRef.Name}) + _, err = gitClient.AddRemote(ctx, upstreamName, upstreamURL, []string{canonicalRepo.Parent.DefaultBranchRef.Name}, git.WithRepoDir(cloneDir)) if err != nil { return err } diff --git a/pkg/cmd/repo/clone/clone_test.go b/pkg/cmd/repo/clone/clone_test.go index 34f2fe431..c6ec872be 100644 --- a/pkg/cmd/repo/clone/clone_test.go +++ b/pkg/cmd/repo/clone/clone_test.go @@ -5,6 +5,7 @@ import ( "strings" "testing" + "github.com/cli/cli/v2/git" "github.com/cli/cli/v2/internal/config" "github.com/cli/cli/v2/internal/run" "github.com/cli/cli/v2/pkg/cmdutil" @@ -104,6 +105,7 @@ func runCloneCommand(httpClient *http.Client, cli string) (*test.CmdOut, error) Config: func() (config.Config, error) { return config.NewBlankConfig(), nil }, + GitClient: &git.Client{GitPath: "some/path/git"}, } cmd := NewCmdClone(fac, nil) diff --git a/pkg/cmd/repo/create/create.go b/pkg/cmd/repo/create/create.go index e311ad727..931ffb926 100644 --- a/pkg/cmd/repo/create/create.go +++ b/pkg/cmd/repo/create/create.go @@ -1,6 +1,7 @@ package create import ( + "context" "errors" "fmt" "net/http" @@ -27,6 +28,7 @@ type iprompter interface { type CreateOptions struct { HttpClient func() (*http.Client, error) + GitClient *git.Client Config func() (config.Config, error) IO *iostreams.IOStreams Prompter iprompter @@ -57,6 +59,7 @@ func NewCmdCreate(f *cmdutil.Factory, runF func(*CreateOptions) error) *cobra.Co opts := &CreateOptions{ IO: f.IOStreams, HttpClient: f.HttpClient, + GitClient: f.GitClient, Config: f.Config, Prompter: f.Prompter, } @@ -390,10 +393,10 @@ func createFromScratch(opts *CreateOptions) error { // use the template's default branch checkoutBranch = templateRepoMainBranch } - if err := localInit(opts.IO, remoteURL, repo.RepoName(), checkoutBranch); err != nil { + if err := localInit(opts.GitClient, remoteURL, repo.RepoName(), checkoutBranch); err != nil { return err } - } else if _, err := git.RunClone(remoteURL, []string{}); err != nil { + } else if _, err := opts.GitClient.Clone(context.Background(), remoteURL, []string{}); err != nil { return err } } @@ -427,6 +430,7 @@ func createFromLocal(opts *CreateOptions) error { } repoPath := opts.Source + opts.GitClient.RepoDir = repoPath var baseRemote string if opts.Remote == "" { @@ -440,7 +444,7 @@ func createFromLocal(opts *CreateOptions) error { return err } - isRepo, err := isLocalRepo(repoPath) + isRepo, err := isLocalRepo(opts.GitClient) if err != nil { return err } @@ -451,7 +455,7 @@ func createFromLocal(opts *CreateOptions) error { return fmt.Errorf("%s is not a git repository. Run `git -C \"%s\" init` to initialize it", absPath, repoPath) } - committed, err := hasCommits(repoPath) + committed, err := hasCommits(opts.GitClient) if err != nil { return err } @@ -533,7 +537,7 @@ func createFromLocal(opts *CreateOptions) error { } } - if err := sourceInit(opts.IO, remoteURL, baseRemote, repoPath); err != nil { + if err := sourceInit(opts.GitClient, opts.IO, remoteURL, baseRemote); err != nil { return err } @@ -547,7 +551,7 @@ func createFromLocal(opts *CreateOptions) error { } if opts.Push { - repoPush, err := git.GitCommand("-C", repoPath, "push", "-u", baseRemote, "HEAD") + repoPush, err := opts.GitClient.Command(context.Background(), "push", "-u", baseRemote, "HEAD") if err != nil { return err } @@ -563,17 +567,17 @@ func createFromLocal(opts *CreateOptions) error { return nil } -func sourceInit(io *iostreams.IOStreams, remoteURL, baseRemote, repoPath string) error { +func sourceInit(gitClient *git.Client, io *iostreams.IOStreams, remoteURL, baseRemote string) error { cs := io.ColorScheme() isTTY := io.IsStdoutTTY() stdout := io.Out - remoteAdd, err := git.GitCommand("-C", repoPath, "remote", "add", baseRemote, remoteURL) + remoteAdd, err := gitClient.Command(context.Background(), "remote", "add", baseRemote, remoteURL) if err != nil { return err } - err = remoteAdd.Run() + _, err = remoteAdd.Output() if err != nil { return fmt.Errorf("%s Unable to add remote %q", cs.FailureIcon(), baseRemote) } @@ -584,12 +588,12 @@ func sourceInit(io *iostreams.IOStreams, remoteURL, baseRemote, repoPath string) } // check if local repository has committed changes -func hasCommits(repoPath string) (bool, error) { - hasCommitsCmd, err := git.GitCommand("-C", repoPath, "rev-parse", "HEAD") +func hasCommits(gitClient *git.Client) (bool, error) { + hasCommitsCmd, err := gitClient.Command(context.Background(), "rev-parse", "HEAD") if err != nil { return false, err } - err = hasCommitsCmd.Run() + _, err = hasCommitsCmd.Output() if err == nil { return true, nil } @@ -606,8 +610,8 @@ func hasCommits(repoPath string) (bool, error) { } // check if path is the top level directory of a git repo -func isLocalRepo(repoPath string) (bool, error) { - projectDir, projectDirErr := git.GetDirFromPath(repoPath) +func isLocalRepo(gitClient *git.Client) (bool, error) { + projectDir, projectDirErr := gitClient.GitDir(context.Background()) if projectDirErr != nil { var execError *exec.ExitError if errors.As(projectDirErr, &execError) { @@ -624,28 +628,26 @@ func isLocalRepo(repoPath string) (bool, error) { } // clone the checkout branch to specified path -func localInit(io *iostreams.IOStreams, remoteURL, path, checkoutBranch string) error { - gitInit, err := git.GitCommand("init", path) +func localInit(gitClient *git.Client, remoteURL, path, checkoutBranch string) error { + ctx := context.Background() + gitInit, err := gitClient.Command(ctx, "init", path) if err != nil { return err } - isTTY := io.IsStdoutTTY() - if isTTY { - gitInit.Stdout = io.Out - } - gitInit.Stderr = io.ErrOut - err = gitInit.Run() + _, err = gitInit.Output() if err != nil { return err } - gitRemoteAdd, err := git.GitCommand("-C", path, "remote", "add", "origin", remoteURL) + // Clone the client so we do not modify the original client's RepoDir. + gc := cloneGitClient(gitClient) + gc.RepoDir = path + + gitRemoteAdd, err := gc.Command(ctx, "remote", "add", "origin", remoteURL) if err != nil { return err } - gitRemoteAdd.Stdout = io.Out - gitRemoteAdd.Stderr = io.ErrOut - err = gitRemoteAdd.Run() + _, err = gitRemoteAdd.Output() if err != nil { return err } @@ -654,24 +656,21 @@ func localInit(io *iostreams.IOStreams, remoteURL, path, checkoutBranch string) return nil } - gitFetch, err := git.GitCommand("-C", path, "fetch", "origin", fmt.Sprintf("+refs/heads/%[1]s:refs/remotes/origin/%[1]s", checkoutBranch)) + gitFetch, err := gc.Command(ctx, "fetch", "origin", fmt.Sprintf("+refs/heads/%[1]s:refs/remotes/origin/%[1]s", checkoutBranch)) if err != nil { return err } - gitFetch.Stdout = io.Out - gitFetch.Stderr = io.ErrOut err = gitFetch.Run() if err != nil { return err } - gitCheckout, err := git.GitCommand("-C", path, "checkout", checkoutBranch) + gitCheckout, err := gc.Command(ctx, "checkout", checkoutBranch) if err != nil { return err } - gitCheckout.Stdout = io.Out - gitCheckout.Stderr = io.ErrOut - return gitCheckout.Run() + _, err = gitCheckout.Output() + return err } func interactiveGitIgnore(client *http.Client, hostname string, prompter iprompter) (string, error) { @@ -736,3 +735,14 @@ func interactiveRepoInfo(prompter iprompter, defaultName string) (string, string return name, description, strings.ToUpper(visibilityOptions[selected]), nil } + +func cloneGitClient(c *git.Client) *git.Client { + return &git.Client{ + GhPath: c.GhPath, + RepoDir: c.RepoDir, + GitPath: c.GitPath, + Stderr: c.Stderr, + Stdin: c.Stdin, + Stdout: c.Stdout, + } +} diff --git a/pkg/cmd/repo/create/create_test.go b/pkg/cmd/repo/create/create_test.go index f8849afd1..a1ce875f0 100644 --- a/pkg/cmd/repo/create/create_test.go +++ b/pkg/cmd/repo/create/create_test.go @@ -6,6 +6,7 @@ import ( "net/http" "testing" + "github.com/cli/cli/v2/git" "github.com/cli/cli/v2/internal/config" "github.com/cli/cli/v2/internal/prompter" "github.com/cli/cli/v2/internal/run" @@ -492,6 +493,8 @@ func Test_createRun(t *testing.T) { return config.NewBlankConfig(), nil } + tt.opts.GitClient = &git.Client{GitPath: "some/path/git"} + ios, _, stdout, stderr := iostreams.Test() ios.SetStdinTTY(tt.tty) ios.SetStdoutTTY(tt.tty) diff --git a/pkg/cmd/repo/fork/fork.go b/pkg/cmd/repo/fork/fork.go index 9416ba4b2..e17e4b34d 100644 --- a/pkg/cmd/repo/fork/fork.go +++ b/pkg/cmd/repo/fork/fork.go @@ -1,6 +1,7 @@ package fork import ( + "context" "fmt" "net/http" "net/url" @@ -9,7 +10,7 @@ import ( "github.com/MakeNowJust/heredoc" "github.com/cli/cli/v2/api" - "github.com/cli/cli/v2/context" + ghContext "github.com/cli/cli/v2/context" "github.com/cli/cli/v2/git" "github.com/cli/cli/v2/internal/config" "github.com/cli/cli/v2/internal/ghrepo" @@ -25,10 +26,11 @@ const defaultRemoteName = "origin" type ForkOptions struct { HttpClient func() (*http.Client, error) + GitClient *git.Client Config func() (config.Config, error) IO *iostreams.IOStreams BaseRepo func() (ghrepo.Interface, error) - Remotes func() (context.Remotes, error) + Remotes func() (ghContext.Remotes, error) Since func(time.Time) time.Duration GitArgs []string @@ -51,6 +53,7 @@ func NewCmdFork(f *cmdutil.Factory, runF func(*ForkOptions) error) *cobra.Comman opts := &ForkOptions{ IO: f.IOStreams, HttpClient: f.HttpClient, + GitClient: f.GitClient, Config: f.Config, BaseRepo: f.BaseRepo, Remotes: f.Remotes, @@ -226,6 +229,9 @@ func forkRun(opts *ForkOptions) error { } protocol, _ := cfg.Get(repoToFork.RepoHost(), "git_protocol") + gitClient := opts.GitClient + ctx := context.Background() + if inParent { remotes, err := opts.Remotes() if err != nil { @@ -264,6 +270,7 @@ func forkRun(opts *ForkOptions) error { return fmt.Errorf("failed to prompt: %w", err) } } + if remoteDesired { remoteName := opts.RemoteName remotes, err := opts.Remotes() @@ -274,11 +281,11 @@ func forkRun(opts *ForkOptions) error { if _, err := remotes.FindByName(remoteName); err == nil { if opts.Rename { renameTarget := "upstream" - renameCmd, err := git.GitCommand("remote", "rename", remoteName, renameTarget) + renameCmd, err := gitClient.Command(ctx, "remote", "rename", remoteName, renameTarget) if err != nil { return err } - err = renameCmd.Run() + _, err = renameCmd.Output() if err != nil { return err } @@ -289,7 +296,7 @@ func forkRun(opts *ForkOptions) error { forkedRepoCloneURL := ghrepo.FormatRemoteURL(forkedRepo, protocol) - _, err = git.AddRemote(remoteName, forkedRepoCloneURL) + _, err = gitClient.AddRemote(ctx, remoteName, forkedRepoCloneURL, []string{}) if err != nil { return fmt.Errorf("failed to add remote: %w", err) } @@ -309,13 +316,13 @@ func forkRun(opts *ForkOptions) error { } if cloneDesired { forkedRepoURL := ghrepo.FormatRemoteURL(forkedRepo, protocol) - cloneDir, err := git.RunClone(forkedRepoURL, opts.GitArgs) + cloneDir, err := gitClient.Clone(ctx, forkedRepoURL, opts.GitArgs) if err != nil { return fmt.Errorf("failed to clone fork: %w", err) } upstreamURL := ghrepo.FormatRemoteURL(repoToFork, protocol) - err = git.AddNamedRemote(upstreamURL, "upstream", cloneDir, []string{}) + _, err = gitClient.AddRemote(ctx, "upstream", upstreamURL, []string{}, git.WithRepoDir(cloneDir)) if err != nil { return err } diff --git a/pkg/cmd/repo/fork/fork_test.go b/pkg/cmd/repo/fork/fork_test.go index 111eb98f6..e5ffda36b 100644 --- a/pkg/cmd/repo/fork/fork_test.go +++ b/pkg/cmd/repo/fork/fork_test.go @@ -700,6 +700,8 @@ func TestRepoFork(t *testing.T) { return tt.remotes, nil } + tt.opts.GitClient = &git.Client{GitPath: "some/path/git"} + //nolint:staticcheck // SA1019: prompt.InitAskStubber is deprecated: use NewAskStubber as, teardown := prompt.InitAskStubber() defer teardown() diff --git a/pkg/cmd/repo/rename/rename.go b/pkg/cmd/repo/rename/rename.go index 0b256d1d3..2d07cd8b0 100644 --- a/pkg/cmd/repo/rename/rename.go +++ b/pkg/cmd/repo/rename/rename.go @@ -1,13 +1,14 @@ package rename import ( + "context" "fmt" "net/http" "github.com/AlecAivazis/survey/v2" "github.com/MakeNowJust/heredoc" "github.com/cli/cli/v2/api" - "github.com/cli/cli/v2/context" + ghContext "github.com/cli/cli/v2/context" "github.com/cli/cli/v2/git" "github.com/cli/cli/v2/internal/config" "github.com/cli/cli/v2/internal/ghrepo" @@ -19,10 +20,11 @@ import ( type RenameOptions struct { HttpClient func() (*http.Client, error) + GitClient *git.Client IO *iostreams.IOStreams Config func() (config.Config, error) BaseRepo func() (ghrepo.Interface, error) - Remotes func() (context.Remotes, error) + Remotes func() (ghContext.Remotes, error) DoConfirm bool HasRepoOverride bool newRepoSelector string @@ -32,6 +34,7 @@ func NewCmdRename(f *cmdutil.Factory, runf func(*RenameOptions) error) *cobra.Co opts := &RenameOptions{ IO: f.IOStreams, HttpClient: f.HttpClient, + GitClient: f.GitClient, Remotes: f.Remotes, Config: f.Config, } @@ -145,7 +148,7 @@ func renameRun(opts *RenameOptions) error { return nil } -func updateRemote(repo ghrepo.Interface, renamed ghrepo.Interface, opts *RenameOptions) (*context.Remote, error) { +func updateRemote(repo ghrepo.Interface, renamed ghrepo.Interface, opts *RenameOptions) (*ghContext.Remote, error) { cfg, err := opts.Config() if err != nil { return nil, err @@ -167,6 +170,7 @@ func updateRemote(repo ghrepo.Interface, renamed ghrepo.Interface, opts *RenameO } remoteURL := ghrepo.FormatRemoteURL(renamed, protocol) - err = git.UpdateRemoteURL(remote.Name, remoteURL) + err = opts.GitClient.UpdateRemoteURL(context.Background(), remote.Name, remoteURL) + return remote, err } diff --git a/pkg/cmd/repo/rename/rename_test.go b/pkg/cmd/repo/rename/rename_test.go index 97e1aebb7..523b2ba4a 100644 --- a/pkg/cmd/repo/rename/rename_test.go +++ b/pkg/cmd/repo/rename/rename_test.go @@ -259,6 +259,8 @@ func TestRenameRun(t *testing.T) { ios.SetStdoutTTY(tt.tty) tt.opts.IO = ios + tt.opts.GitClient = &git.Client{GitPath: "some/path/git"} + t.Run(tt.name, func(t *testing.T) { defer reg.Verify(t) err := renameRun(&tt.opts) diff --git a/pkg/cmd/repo/sync/git.go b/pkg/cmd/repo/sync/git.go index 1ec86b053..fbb63a2ec 100644 --- a/pkg/cmd/repo/sync/git.go +++ b/pkg/cmd/repo/sync/git.go @@ -1,11 +1,11 @@ package sync import ( + "context" "fmt" "strings" "github.com/cli/cli/v2/git" - "github.com/cli/cli/v2/pkg/iostreams" ) type gitClient interface { @@ -22,12 +22,12 @@ type gitClient interface { } type gitExecuter struct { - io *iostreams.IOStreams + client *git.Client } func (g *gitExecuter) BranchRemote(branch string) (string, error) { args := []string{"rev-parse", "--symbolic-full-name", "--abbrev-ref", fmt.Sprintf("%s@{u}", branch)} - cmd, err := git.GitCommand(args...) + cmd, err := g.client.Command(context.Background(), args...) if err != nil { return "", err } @@ -40,60 +40,60 @@ func (g *gitExecuter) BranchRemote(branch string) (string, error) { } func (g *gitExecuter) UpdateBranch(branch, ref string) error { - cmd, err := git.GitCommand("update-ref", fmt.Sprintf("refs/heads/%s", branch), ref) + cmd, err := g.client.Command(context.Background(), "update-ref", fmt.Sprintf("refs/heads/%s", branch), ref) if err != nil { return err } - return cmd.Run() + _, err = cmd.Output() + return err } func (g *gitExecuter) CreateBranch(branch, ref, upstream string) error { - cmd, err := git.GitCommand("branch", branch, ref) + ctx := context.Background() + cmd, err := g.client.Command(ctx, "branch", branch, ref) if err != nil { return err } - if err := cmd.Run(); err != nil { + if _, err := cmd.Output(); err != nil { return err } - cmd, err = git.GitCommand("branch", "--set-upstream-to", upstream, branch) + cmd, err = g.client.Command(ctx, "branch", "--set-upstream-to", upstream, branch) if err != nil { return err } - return cmd.Run() + _, err = cmd.Output() + return err } func (g *gitExecuter) CurrentBranch() (string, error) { - return git.CurrentBranch() + return g.client.CurrentBranch(context.Background()) } func (g *gitExecuter) Fetch(remote, ref string) error { args := []string{"fetch", "-q", remote, ref} - cmd, err := git.GitCommand(args...) + cmd, err := g.client.Command(context.Background(), args...) if err != nil { return err } - cmd.Stdin = g.io.In - cmd.Stdout = g.io.Out - cmd.Stderr = g.io.ErrOut return cmd.Run() } func (g *gitExecuter) HasLocalBranch(branch string) bool { - return git.HasLocalBranch(branch) + return g.client.HasLocalBranch(context.Background(), branch) } func (g *gitExecuter) IsAncestor(ancestor, progeny string) (bool, error) { args := []string{"merge-base", "--is-ancestor", ancestor, progeny} - cmd, err := git.GitCommand(args...) + cmd, err := g.client.Command(context.Background(), args...) if err != nil { return false, err } - err = cmd.Run() + _, err = cmd.Output() return err == nil, nil } func (g *gitExecuter) IsDirty() (bool, error) { - cmd, err := git.GitCommand("status", "--untracked-files=no", "--porcelain") + cmd, err := g.client.Command(context.Background(), "status", "--untracked-files=no", "--porcelain") if err != nil { return false, err } @@ -109,18 +109,20 @@ func (g *gitExecuter) IsDirty() (bool, error) { func (g *gitExecuter) MergeFastForward(ref string) error { args := []string{"merge", "--ff-only", "--quiet", ref} - cmd, err := git.GitCommand(args...) + cmd, err := g.client.Command(context.Background(), args...) if err != nil { return err } - return cmd.Run() + _, err = cmd.Output() + return err } func (g *gitExecuter) ResetHard(ref string) error { args := []string{"reset", "--hard", ref} - cmd, err := git.GitCommand(args...) + cmd, err := g.client.Command(context.Background(), args...) if err != nil { return err } - return cmd.Run() + _, err = cmd.Output() + return err } diff --git a/pkg/cmd/repo/sync/sync.go b/pkg/cmd/repo/sync/sync.go index ba2fdbafb..eebec4389 100644 --- a/pkg/cmd/repo/sync/sync.go +++ b/pkg/cmd/repo/sync/sync.go @@ -39,7 +39,7 @@ func NewCmdSync(f *cmdutil.Factory, runF func(*SyncOptions) error) *cobra.Comman IO: f.IOStreams, BaseRepo: f.BaseRepo, Remotes: f.Remotes, - Git: &gitExecuter{io: f.IOStreams}, + Git: &gitExecuter{client: f.GitClient}, } cmd := &cobra.Command{ From a55c1660841d6108b9e81548a8d0d965ec1278bc Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Fri, 21 Oct 2022 14:27:07 +0000 Subject: [PATCH 44/44] Bump microsoft/setup-msbuild from 1.0.3 to 1.1.3 Bumps [microsoft/setup-msbuild](https://github.com/microsoft/setup-msbuild) from 1.0.3 to 1.1.3. - [Release notes](https://github.com/microsoft/setup-msbuild/releases) - [Changelog](https://github.com/microsoft/setup-msbuild/blob/master/building-release.md) - [Commits](https://github.com/microsoft/setup-msbuild/compare/v1.0.3...v1.1.3) --- updated-dependencies: - dependency-name: microsoft/setup-msbuild dependency-type: direct:production update-type: version-update:semver-minor ... Signed-off-by: dependabot[bot] --- .github/workflows/releases.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/releases.yml b/.github/workflows/releases.yml index a2a0c57c1..e6f99dd4c 100644 --- a/.github/workflows/releases.yml +++ b/.github/workflows/releases.yml @@ -147,7 +147,7 @@ jobs: GITHUB_TOKEN: ${{secrets.GITHUB_TOKEN}} - name: Prepare PATH id: setupmsbuild - uses: microsoft/setup-msbuild@v1.0.3 + uses: microsoft/setup-msbuild@v1.1.3 - name: Build MSI id: buildmsi shell: bash