mirror of
https://github.com/fiatjaf/nak.git
synced 2025-12-08 16:48:51 +00:00
git clone
This commit is contained in:
257
git.go
257
git.go
@@ -3,6 +3,7 @@ package main
|
|||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"net/url"
|
||||||
"os"
|
"os"
|
||||||
"os/exec"
|
"os/exec"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
@@ -33,6 +34,7 @@ var git = &cli.Command{
|
|||||||
Usage: "git-related operations",
|
Usage: "git-related operations",
|
||||||
Commands: []*cli.Command{
|
Commands: []*cli.Command{
|
||||||
gitInit,
|
gitInit,
|
||||||
|
gitClone,
|
||||||
gitPush,
|
gitPush,
|
||||||
gitPull,
|
gitPull,
|
||||||
gitFetch,
|
gitFetch,
|
||||||
@@ -236,7 +238,7 @@ var gitInit = &cli.Command{
|
|||||||
if repoNpub != ownerNpub {
|
if repoNpub != ownerNpub {
|
||||||
return fmt.Errorf("git remote npub '%s' does not match owner '%s'", repoNpub, ownerNpub)
|
return fmt.Errorf("git remote npub '%s' does not match owner '%s'", repoNpub, ownerNpub)
|
||||||
}
|
}
|
||||||
if !slices.Contains(config.GraspServers, relayHostname) {
|
if !slices.Contains(config.GraspServers, nostr.NormalizeURL(relayHostname)) {
|
||||||
return fmt.Errorf("git remote relay '%s' not in grasp servers %v", relayHostname, config.GraspServers)
|
return fmt.Errorf("git remote relay '%s' not in grasp servers %v", relayHostname, config.GraspServers)
|
||||||
}
|
}
|
||||||
if identifier != config.Identifier {
|
if identifier != config.Identifier {
|
||||||
@@ -383,6 +385,188 @@ func promptForConfig(config *Nip34Config) error {
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
var gitClone = &cli.Command{
|
||||||
|
Name: "clone",
|
||||||
|
Usage: "clone a NIP-34 repository from a nostr:// URI",
|
||||||
|
ArgsUsage: "nostr://<npub>/<relay>/<identifier> [directory]",
|
||||||
|
Action: func(ctx context.Context, c *cli.Command) error {
|
||||||
|
args := c.Args()
|
||||||
|
if args.Len() == 0 {
|
||||||
|
return fmt.Errorf("missing repository URI (expected nostr://<npub>/<relay>/<identifier>)")
|
||||||
|
}
|
||||||
|
|
||||||
|
repoURI := args.Get(0)
|
||||||
|
if !strings.HasPrefix(repoURI, "nostr://") {
|
||||||
|
return fmt.Errorf("invalid nostr URI: %s", repoURI)
|
||||||
|
}
|
||||||
|
|
||||||
|
uriParts := strings.Split(strings.TrimPrefix(repoURI, "nostr://"), "/")
|
||||||
|
if len(uriParts) != 3 {
|
||||||
|
return fmt.Errorf("invalid nostr URI format, expected nostr://<npub>/<relay>/<identifier>, got: %s", repoURI)
|
||||||
|
}
|
||||||
|
|
||||||
|
ownerNpub := uriParts[0]
|
||||||
|
relayHost := uriParts[1]
|
||||||
|
identifier := uriParts[2]
|
||||||
|
|
||||||
|
prefix, decoded, err := nip19.Decode(ownerNpub)
|
||||||
|
if err != nil || prefix != "npub" {
|
||||||
|
return fmt.Errorf("invalid owner npub in URI: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
ownerPk := decoded.(nostr.PubKey)
|
||||||
|
primaryRelay := nostr.NormalizeURL(relayHost)
|
||||||
|
|
||||||
|
// fetch repository announcement (30617)
|
||||||
|
relays := appendUnique([]string{primaryRelay}, sys.FetchOutboxRelays(ctx, ownerPk, 3)...)
|
||||||
|
var repo nip34.Repository
|
||||||
|
for ie := range sys.Pool.FetchMany(ctx, relays, nostr.Filter{
|
||||||
|
Kinds: []nostr.Kind{30617},
|
||||||
|
Authors: []nostr.PubKey{ownerPk},
|
||||||
|
Tags: nostr.TagMap{
|
||||||
|
"d": []string{identifier},
|
||||||
|
},
|
||||||
|
Limit: 2,
|
||||||
|
}, nostr.SubscriptionOptions{Label: "nak-git-clone-meta"}) {
|
||||||
|
if ie.Event.CreatedAt > repo.CreatedAt {
|
||||||
|
repo = nip34.ParseRepository(ie.Event)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if repo.Event.ID == nostr.ZeroID {
|
||||||
|
return fmt.Errorf("no repository announcement (kind 30617) found for %s", identifier)
|
||||||
|
}
|
||||||
|
|
||||||
|
// fetch repository state (30618)
|
||||||
|
var state nip34.RepositoryState
|
||||||
|
var stateFound bool
|
||||||
|
var stateErr error
|
||||||
|
for ie := range sys.Pool.FetchMany(ctx, repo.Relays, nostr.Filter{
|
||||||
|
Kinds: []nostr.Kind{30618},
|
||||||
|
Authors: []nostr.PubKey{ownerPk},
|
||||||
|
Tags: nostr.TagMap{
|
||||||
|
"d": []string{identifier},
|
||||||
|
},
|
||||||
|
Limit: 2,
|
||||||
|
}, nostr.SubscriptionOptions{Label: "nak-git-clone-meta"}) {
|
||||||
|
if ie.Event.CreatedAt > state.CreatedAt {
|
||||||
|
state = nip34.ParseRepositoryState(ie.Event)
|
||||||
|
stateFound = true
|
||||||
|
|
||||||
|
if state.HEAD == "" {
|
||||||
|
stateErr = fmt.Errorf("state is missing HEAD")
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if _, ok := state.Branches[state.HEAD]; !ok {
|
||||||
|
stateErr = fmt.Errorf("state is missing commit for HEAD branch '%s'", state.HEAD)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
stateErr = nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if !stateFound {
|
||||||
|
return fmt.Errorf("no repository state (kind 30618) found")
|
||||||
|
}
|
||||||
|
if stateErr != nil {
|
||||||
|
return stateErr
|
||||||
|
}
|
||||||
|
|
||||||
|
// determine target directory
|
||||||
|
targetDir := ""
|
||||||
|
if args.Len() >= 2 {
|
||||||
|
targetDir = args.Get(1)
|
||||||
|
} else {
|
||||||
|
targetDir = repo.ID
|
||||||
|
}
|
||||||
|
if targetDir == "" {
|
||||||
|
targetDir = identifier
|
||||||
|
}
|
||||||
|
|
||||||
|
// if targetDir exists and is non-empty, bail
|
||||||
|
if fi, err := os.Stat(targetDir); err == nil && fi.IsDir() {
|
||||||
|
entries, err := os.ReadDir(targetDir)
|
||||||
|
if err == nil && len(entries) > 0 {
|
||||||
|
return fmt.Errorf("target directory '%s' already exists and is not empty", targetDir)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// decide which clone URL to use
|
||||||
|
if len(repo.Clone) == 0 {
|
||||||
|
return fmt.Errorf("no clone urls found for repository")
|
||||||
|
}
|
||||||
|
|
||||||
|
cloned := false
|
||||||
|
for _, url := range repo.Clone {
|
||||||
|
log("- cloning %s... ", color.CyanString(url))
|
||||||
|
if err := tryCloneAndCheckState(ctx, url, targetDir, &state); err != nil {
|
||||||
|
log(color.YellowString("failed: %v\n", err))
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
log("%s\n", color.GreenString("ok"))
|
||||||
|
cloned = true
|
||||||
|
break
|
||||||
|
}
|
||||||
|
|
||||||
|
if !cloned {
|
||||||
|
return fmt.Errorf("failed to clone")
|
||||||
|
}
|
||||||
|
|
||||||
|
// write nip34.json inside cloned directory
|
||||||
|
// normalize relay URLs for consistency
|
||||||
|
normalizedRelays := make([]string, 0, len(repo.Relays))
|
||||||
|
for _, r := range repo.Relays {
|
||||||
|
normalizedRelays = append(normalizedRelays, nostr.NormalizeURL(r))
|
||||||
|
}
|
||||||
|
|
||||||
|
cfg := Nip34Config{
|
||||||
|
Identifier: repo.ID,
|
||||||
|
Name: repo.Name,
|
||||||
|
Description: repo.Description,
|
||||||
|
Web: repo.Web,
|
||||||
|
Owner: nip19.EncodeNpub(repo.Event.PubKey),
|
||||||
|
GraspServers: normalizedRelays,
|
||||||
|
EarliestUniqueCommit: repo.EarliestUniqueCommitID,
|
||||||
|
Maintainers: make([]string, 0, len(repo.Maintainers)),
|
||||||
|
}
|
||||||
|
for _, m := range repo.Maintainers {
|
||||||
|
cfg.Maintainers = append(cfg.Maintainers, nip19.EncodeNpub(m))
|
||||||
|
}
|
||||||
|
|
||||||
|
data, err := json.MarshalIndent(cfg, "", " ")
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("failed to marshal nip34.json: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
configPath := filepath.Join(targetDir, "nip34.json")
|
||||||
|
if err := os.WriteFile(configPath, data, 0644); err != nil {
|
||||||
|
return fmt.Errorf("failed to write %s: %w", configPath, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// add nip34.json to .git/info/exclude in cloned repo
|
||||||
|
gitDir := filepath.Join(targetDir, ".git")
|
||||||
|
if st, err := os.Stat(gitDir); err == nil && st.IsDir() {
|
||||||
|
excludePath := filepath.Join(gitDir, "info", "exclude")
|
||||||
|
excludeContent, err := os.ReadFile(excludePath)
|
||||||
|
if err != nil {
|
||||||
|
excludeContent = []byte("")
|
||||||
|
}
|
||||||
|
if !strings.Contains(string(excludeContent), "nip34.json") {
|
||||||
|
newContent := string(excludeContent)
|
||||||
|
if len(newContent) > 0 && !strings.HasSuffix(newContent, "\n") {
|
||||||
|
newContent += "\n"
|
||||||
|
}
|
||||||
|
newContent += "nip34.json\n"
|
||||||
|
if err := os.WriteFile(excludePath, []byte(newContent), 0644); err != nil {
|
||||||
|
log(color.YellowString("failed to add nip34.json to %s: %v\n", excludePath, err))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
log("cloned into %s\n", color.GreenString(targetDir))
|
||||||
|
return nil
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
var gitPush = &cli.Command{
|
var gitPush = &cli.Command{
|
||||||
Name: "push",
|
Name: "push",
|
||||||
Usage: "push git changes",
|
Usage: "push git changes",
|
||||||
@@ -445,9 +629,13 @@ var gitPush = &cli.Command{
|
|||||||
})
|
})
|
||||||
for ie := range results {
|
for ie := range results {
|
||||||
if ie.Event.Kind == 30617 {
|
if ie.Event.Kind == 30617 {
|
||||||
repo = nip34.ParseRepository(ie.Event)
|
if ie.Event.CreatedAt > repo.CreatedAt {
|
||||||
|
repo = nip34.ParseRepository(ie.Event)
|
||||||
|
}
|
||||||
} else if ie.Event.Kind == 30618 {
|
} else if ie.Event.Kind == 30618 {
|
||||||
state = nip34.ParseRepositoryState(ie.Event)
|
if ie.Event.CreatedAt > state.CreatedAt {
|
||||||
|
state = nip34.ParseRepositoryState(ie.Event)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -457,7 +645,7 @@ var gitPush = &cli.Command{
|
|||||||
|
|
||||||
// check if signer matches owner or is in maintainers
|
// check if signer matches owner or is in maintainers
|
||||||
if currentPk != ownerPk && !slices.Contains(repo.Maintainers, currentPk) {
|
if currentPk != ownerPk && !slices.Contains(repo.Maintainers, currentPk) {
|
||||||
return fmt.Errorf("current user is not allowed to push")
|
return fmt.Errorf("current user '%s' is not allowed to push", nip19.EncodeNpub(currentPk))
|
||||||
}
|
}
|
||||||
|
|
||||||
if state.Event.ID != nostr.ZeroID {
|
if state.Event.ID != nostr.ZeroID {
|
||||||
@@ -789,8 +977,67 @@ func gitSanityCheck(
|
|||||||
if remoteIdentifier != localConfig.Identifier {
|
if remoteIdentifier != localConfig.Identifier {
|
||||||
return nostr.ZeroPK, fmt.Errorf("git remote identifier '%s' differs from nip34.json identifier '%s'", 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) {
|
if !slices.Contains(localConfig.GraspServers, nostr.NormalizeURL(remoteHostname)) {
|
||||||
return nostr.ZeroPK, fmt.Errorf("git remote relay '%s' not in grasp servers %v", remoteHostname, localConfig.GraspServers)
|
return nostr.ZeroPK, fmt.Errorf("git remote relay '%s' not in grasp servers %v", remoteHostname, localConfig.GraspServers)
|
||||||
}
|
}
|
||||||
return ownerPk, nil
|
return ownerPk, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func tryCloneAndCheckState(ctx context.Context, cloneURL, targetDir string, state *nip34.RepositoryState) (err error) {
|
||||||
|
// if we get here we know we were the ones who created the target directory, so we're safe to remove it
|
||||||
|
defer func() {
|
||||||
|
if err != nil {
|
||||||
|
if err := os.RemoveAll(targetDir); err != nil {
|
||||||
|
log("failed to remove '%s' when handling error from clone: %s", targetDir, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
|
||||||
|
cmd := exec.CommandContext(ctx, "git", "clone", cloneURL, targetDir)
|
||||||
|
output, err := cmd.CombinedOutput()
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("git clone failed: %v: %s", err, strings.TrimSpace(string(output)))
|
||||||
|
}
|
||||||
|
|
||||||
|
// if we don't have any state information, we can't verify anything
|
||||||
|
if state == nil || state.Event.ID == nostr.ZeroID {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// check that the HEAD branch matches the state HEAD
|
||||||
|
cmd = exec.Command("git", "-C", targetDir, "rev-parse", "--abbrev-ref", "HEAD")
|
||||||
|
headOut, err := cmd.Output()
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("failed to read HEAD")
|
||||||
|
}
|
||||||
|
currentBranch := strings.TrimSpace(string(headOut))
|
||||||
|
if currentBranch != state.HEAD {
|
||||||
|
return fmt.Errorf("received HEAD '%s' isn't the expected '%s'", currentBranch, state.HEAD)
|
||||||
|
}
|
||||||
|
|
||||||
|
// verify the HEAD branch only as it's the only one we have
|
||||||
|
expectedCommit := state.Branches[state.HEAD] // we've tested before if state has this
|
||||||
|
cmd = exec.Command("git", "-C", targetDir, "rev-parse", state.HEAD)
|
||||||
|
actualOut, err := cmd.Output()
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("failed to check commit for '%s': %s", state.HEAD, err)
|
||||||
|
}
|
||||||
|
actualCommit := strings.TrimSpace(string(actualOut))
|
||||||
|
if actualCommit != expectedCommit {
|
||||||
|
return fmt.Errorf("branch %s is at %s, expected %s", state.HEAD, actualCommit, expectedCommit)
|
||||||
|
}
|
||||||
|
|
||||||
|
// set nostr remote
|
||||||
|
parsed, _ := url.Parse(cloneURL)
|
||||||
|
repoURI := fmt.Sprintf("nostr://%s/%s/%s",
|
||||||
|
nip19.EncodeNpub(state.PubKey),
|
||||||
|
parsed.Host,
|
||||||
|
state.ID,
|
||||||
|
)
|
||||||
|
cmd = exec.Command("git", "-C", targetDir, "remote", "set-url", "origin", repoURI)
|
||||||
|
if err := cmd.Run(); err != nil {
|
||||||
|
return fmt.Errorf("failed to add git remote: %v\n", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user