diff --git a/git.go b/git.go index e6fe306..a6501a3 100644 --- a/git.go +++ b/git.go @@ -212,25 +212,8 @@ var gitInit = &cli.Command{ ownerNpub := nip19.EncodeNpub(pk) // check existing git remotes - cmd = exec.Command("git", "remote", "-v") - output, err := cmd.Output() + nostrRemote, _, _, err := getGitNostrRemote(c) if err != nil { - return fmt.Errorf("failed to get git remotes: %w", err) - } - - remotes := strings.Split(strings.TrimSpace(string(output)), "\n") - var nostrRemote string - for _, remote := range remotes { - if strings.Contains(remote, "nostr://") { - parts := strings.Fields(remote) - if len(parts) >= 2 { - nostrRemote = parts[1] - break - } - } - } - - if nostrRemote == "" { remoteURL := fmt.Sprintf("nostr://%s/%s/%s", ownerNpub, config.GraspServers[0], config.Identifier) cmd = exec.Command("git", "remote", "add", "origin", remoteURL) if err := cmd.Run(); err != nil { @@ -400,37 +383,14 @@ func promptForConfig(config *Nip34Config) error { return nil } -func gitSanityCheck(localConfig Nip34Config, nostrRemote string) (nostr.PubKey, error) { - urlParts := strings.TrimPrefix(nostrRemote, "nostr://") - parts := strings.Split(urlParts, "/") - if len(parts) != 3 { - return nostr.ZeroPK, fmt.Errorf("invalid nostr URL format, expected nostr:////, got: %s", nostrRemote) - } - - remoteNpub := parts[0] - remoteHostname := parts[1] - remoteIdentifier := parts[2] - - ownerPk, err := parsePubKey(localConfig.Owner) - if err != nil { - return nostr.ZeroPK, fmt.Errorf("invalid owner public key: %w", err) - } - if nip19.EncodeNpub(ownerPk) != remoteNpub { - return nostr.ZeroPK, fmt.Errorf("owner in nip34.json does not match git remote npub") - } - if remoteIdentifier != localConfig.Identifier { - return nostr.ZeroPK, fmt.Errorf("git remote identifier '%s' differs from nip34.json identifier '%s'", remoteIdentifier, localConfig.Identifier) - } - if !slices.Contains(localConfig.GraspServers, remoteHostname) { - return nostr.ZeroPK, fmt.Errorf("git remote relay '%s' not in grasp servers %v", remoteHostname, localConfig.GraspServers) - } - return ownerPk, nil -} - var gitPush = &cli.Command{ Name: "push", Usage: "push git changes", - Flags: defaultKeyFlags, + Flags: append(defaultKeyFlags, &cli.BoolFlag{ + Name: "force", + Aliases: []string{"f"}, + Usage: "force push to git remotes", + }), Action: func(ctx context.Context, c *cli.Command) error { // setup signer kr, _, err := gatherKeyerFromArguments(ctx, c) @@ -455,26 +415,9 @@ var gitPush = &cli.Command{ } // get git remotes - cmd := exec.Command("git", "remote", "-v") - output, err := cmd.Output() + nostrRemote, localBranch, remoteBranch, err := getGitNostrRemote(c) if err != nil { - return fmt.Errorf("failed to get git remotes: %w", err) - } - - remotes := strings.Split(strings.TrimSpace(string(output)), "\n") - var nostrRemote string - for _, remote := range remotes { - if strings.Contains(remote, "nostr://") { - parts := strings.Fields(remote) - if len(parts) >= 2 { - nostrRemote = parts[1] - break - } - } - } - - if nostrRemote == "" { - return fmt.Errorf("no nostr:// remote found") + return err } // parse the URL: nostr://// @@ -521,20 +464,14 @@ var gitPush = &cli.Command{ log("found state event: %s\n", state.Event.ID) } - // get current branch and commit - res, err := exec.Command("git", "rev-parse", "--abbrev-ref", "HEAD").Output() + // get commit for the local branch + res, err := exec.Command("git", "rev-parse", localBranch).Output() if err != nil { - return fmt.Errorf("failed to get current branch: %w", err) - } - currentBranch := strings.TrimSpace(string(res)) - - res, err = exec.Command("git", "rev-parse", "HEAD").Output() - if err != nil { - return fmt.Errorf("failed to get current commit: %w", err) + return fmt.Errorf("failed to get commit for branch %s: %w", localBranch, err) } currentCommit := strings.TrimSpace(string(res)) - log("current branch: %s, commit: %s\n", currentBranch, currentCommit) + log("pushing branch %s to remote branch %s, commit: %s\n", localBranch, remoteBranch, currentCommit) // create a new state if we didn't find any if state.Event.ID == nostr.ZeroID { @@ -546,13 +483,22 @@ var gitPush = &cli.Command{ } // update the branch - state.Branches[currentBranch] = currentCommit - log("> setting branch %s to commit %s\n", currentBranch, currentCommit) + if !c.Bool("force") { + if prevCommit, exists := state.Branches[remoteBranch]; exists { + // check if prevCommit is an ancestor of currentCommit (fast-forward check) + cmd := exec.Command("git", "merge-base", "--is-ancestor", prevCommit, currentCommit) + if err := cmd.Run(); err != nil { + return fmt.Errorf("non-fast-forward push not allowed, use --force to override") + } + } + } + state.Branches[remoteBranch] = currentCommit + log("> setting branch %s to commit %s\n", remoteBranch, currentCommit) - // set the HEAD to the current branch if none is set + // set the HEAD to the local branch if none is set if state.HEAD == "" { - state.HEAD = currentBranch - log("> setting HEAD to branch %s\n", currentBranch) + state.HEAD = remoteBranch + log("> setting HEAD to branch %s\n", remoteBranch) } // create and sign the new state event @@ -573,13 +519,21 @@ var gitPush = &cli.Command{ // push to git clone URLs for _, cloneURL := range repo.Clone { - log("> pushing to: %s\n", cloneURL) - cmd := exec.Command("git", "push", cloneURL, fmt.Sprintf("refs/heads/%s:refs/heads/%s", currentBranch, currentBranch)) + log("> pushing to: %s\n", color.CyanString(cloneURL)) + args := []string{"push"} + if c.Bool("force") { + args = append(args, "--force") + } + args = append(args, + cloneURL, + fmt.Sprintf("refs/heads/%s:refs/heads/%s", localBranch, remoteBranch), + ) + cmd := exec.Command("git", args...) output, err := cmd.CombinedOutput() if err != nil { - log("(!) failed to push to %s: %v\n%s\n", cloneURL, err, string(output)) + log("> failed to push to %s: %v\n%s\n", color.RedString(cloneURL), err, string(output)) } else { - log("> successfully pushed to %s\n", cloneURL) + log("> successfully pushed to %s\n", color.GreenString(cloneURL)) } } @@ -626,26 +580,9 @@ var gitAnnounce = &cli.Command{ } // get git remotes - cmd = exec.Command("git", "remote", "-v") - output, err := cmd.Output() + nostrRemote, _, _, err := getGitNostrRemote(c) if err != nil { - return fmt.Errorf("failed to get git remotes: %w", err) - } - - remotes := strings.Split(strings.TrimSpace(string(output)), "\n") - var nostrRemote string - for _, remote := range remotes { - if strings.Contains(remote, "nostr://") { - parts := strings.Fields(remote) - if len(parts) >= 2 { - nostrRemote = parts[1] - break - } - } - } - - if nostrRemote == "" { - return fmt.Errorf("no nostr:// remote found") + return err } ownerPk, err := gitSanityCheck(localConfig, nostrRemote) @@ -734,3 +671,117 @@ var gitAnnounce = &cli.Command{ return nil }, } + +func getGitNostrRemote(c *cli.Command) ( + remoteURL string, + localBranch string, + remoteBranch string, + err error, +) { + // remote + var remoteName string + var cmd *exec.Cmd + args := c.Args() + if args.Len() > 0 { + remoteName = args.Get(0) + } else { + // get current branch + cmd = exec.Command("git", "rev-parse", "--abbrev-ref", "HEAD") + output, err := cmd.Output() + if err != nil { + return "", "", "", fmt.Errorf("failed to get current branch: %w", err) + } + branch := strings.TrimSpace(string(output)) + // get remote for branch + cmd = exec.Command("git", "config", "--get", fmt.Sprintf("branch.%s.remote", branch)) + output, err = cmd.Output() + if err != nil { + remoteName = "origin" + } else { + remoteName = strings.TrimSpace(string(output)) + } + } + // get the URL + cmd = exec.Command("git", "remote", "get-url", remoteName) + output, err := cmd.Output() + if err != nil { + return "", "", "", fmt.Errorf("remote '%s' does not exist", remoteName) + } + remoteURL = strings.TrimSpace(string(output)) + if !strings.Contains(remoteURL, "nostr://") { + return "", "", "", fmt.Errorf("remote '%s' is not a nostr remote: %s", remoteName, remoteURL) + } + + // branch (local and remote) + if args.Len() > 1 { + branchSpec := args.Get(1) + if strings.Contains(branchSpec, ":") { + parts := strings.Split(branchSpec, ":") + if len(parts) == 2 { + localBranch = parts[0] + remoteBranch = parts[1] + } else { + return "", "", "", fmt.Errorf("invalid branch spec: %s", branchSpec) + } + } else { + localBranch = branchSpec + } + } else { + // get current branch + cmd = exec.Command("git", "rev-parse", "--abbrev-ref", "HEAD") + output, err := cmd.Output() + if err != nil { + return "", "", "", fmt.Errorf("failed to get current branch: %w", err) + } + localBranch = strings.TrimSpace(string(output)) + } + + // get the upstream branch from git config + cmd = exec.Command("git", "config", "--get", fmt.Sprintf("branch.%s.merge", localBranch)) + output, err = cmd.Output() + if err == nil { + // parse refs/heads/ to get just the branch name + mergeRef := strings.TrimSpace(string(output)) + if strings.HasPrefix(mergeRef, "refs/heads/") { + remoteBranch = strings.TrimPrefix(mergeRef, "refs/heads/") + } else { + // fallback if it's not in expected format + remoteBranch = localBranch + } + } else { + // no upstream configured, assume same branch name + remoteBranch = localBranch + } + + return remoteURL, localBranch, remoteBranch, nil +} + +func gitSanityCheck( + localConfig Nip34Config, + nostrRemote string, +) (nostr.PubKey, error) { + urlParts := strings.TrimPrefix(nostrRemote, "nostr://") + parts := strings.Split(urlParts, "/") + if len(parts) != 3 { + return nostr.ZeroPK, fmt.Errorf("invalid nostr URL format, expected nostr:////, got: %s", nostrRemote) + } + + remoteNpub := parts[0] + remoteHostname := parts[1] + remoteIdentifier := parts[2] + + ownerPk, err := parsePubKey(localConfig.Owner) + if err != nil { + return nostr.ZeroPK, fmt.Errorf("invalid owner public key: %w", err) + } + if nip19.EncodeNpub(ownerPk) != remoteNpub { + return nostr.ZeroPK, fmt.Errorf("owner in nip34.json does not match git remote npub") + } + if remoteIdentifier != localConfig.Identifier { + return nostr.ZeroPK, fmt.Errorf("git remote identifier '%s' differs from nip34.json identifier '%s'", remoteIdentifier, localConfig.Identifier) + } + if !slices.Contains(localConfig.GraspServers, remoteHostname) { + return nostr.ZeroPK, fmt.Errorf("git remote relay '%s' not in grasp servers %v", remoteHostname, localConfig.GraspServers) + } + return ownerPk, nil +} diff --git a/go.mod b/go.mod index 2452abf..c077c35 100644 --- a/go.mod +++ b/go.mod @@ -30,6 +30,7 @@ require ( github.com/FastFilter/xorfilter v0.2.1 // indirect github.com/ImVexed/fasturl v0.0.0-20230304231329-4e41488060f3 // indirect github.com/andybalholm/brotli v1.1.1 // indirect + github.com/bluekeyes/go-gitdiff v0.7.1 // indirect github.com/btcsuite/btcd v0.24.2 // indirect github.com/btcsuite/btcd/btcutil v1.1.5 // indirect github.com/btcsuite/btcd/chaincfg/chainhash v1.1.0 // indirect diff --git a/go.sum b/go.sum index 0b64989..c42b1cc 100644 --- a/go.sum +++ b/go.sum @@ -13,6 +13,8 @@ github.com/andybalholm/brotli v1.1.1 h1:PR2pgnyFznKEugtsUo0xLdDop5SKXd5Qf5ysW+7X github.com/andybalholm/brotli v1.1.1/go.mod h1:05ib4cKhjx3OQYUY22hTVd34Bc8upXjOLL2rKwwZBoA= github.com/bep/debounce v1.2.1 h1:v67fRdBA9UQu2NhLFXrSg0Brw7CexQekrBwDMM8bzeY= github.com/bep/debounce v1.2.1/go.mod h1:H8yggRPQKLUhUoqrJC1bO2xNya7vanpDl7xR3ISbCJ0= +github.com/bluekeyes/go-gitdiff v0.7.1 h1:graP4ElLRshr8ecu0UtqfNTCHrtSyZd3DABQm/DWesQ= +github.com/bluekeyes/go-gitdiff v0.7.1/go.mod h1:QpfYYO1E0fTVHVZAZKiRjtSGY9823iCdvGXBcEzHGbM= github.com/btcsuite/btcd v0.20.1-beta/go.mod h1:wVuoA8VJLEcwgqHBwHmzLRazpKxTv13Px/pDuV7OomQ= github.com/btcsuite/btcd v0.22.0-beta.0.20220111032746-97732e52810c/go.mod h1:tjmYdS6MLJ5/s0Fj4DbLgSbDHbEqLJrtnHecBFkdz5M= github.com/btcsuite/btcd v0.23.5-0.20231215221805-96c9fd8078fd/go.mod h1:nm3Bko6zh6bWP60UxwoT5LzdGJsQJaPo6HjduXq9p6A= @@ -92,8 +94,8 @@ github.com/golang/snappy v0.0.4/go.mod h1:/XxbfmMg8lxefKM7IXC3fBNl/7bRcc72aCRzEW github.com/google/go-cmp v0.3.0/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU= github.com/google/go-cmp v0.3.1/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU= github.com/google/go-cmp v0.4.0/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= -github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= -github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= +github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI= +github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg= github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=