git: 'download' command for downloading individual files.

This commit is contained in:
fiatjaf
2026-03-26 17:38:09 -03:00
parent ec1721cfe3
commit 0dbe14aa93
3 changed files with 194 additions and 4 deletions

192
git.go
View File

@@ -2,6 +2,7 @@ package main
import (
"context"
"errors"
"fmt"
"os"
"os/exec"
@@ -16,6 +17,8 @@ import (
"fiatjaf.com/nostr"
"fiatjaf.com/nostr/nip19"
"fiatjaf.com/nostr/nip34"
"fiatjaf.com/nostr/nip34/gitnaturalapi"
"fiatjaf.com/nostr/nip34/grasp"
"github.com/AlecAivazis/survey/v2"
"github.com/fatih/color"
"github.com/urfave/cli/v3"
@@ -459,6 +462,190 @@ aside from those, there is also:
return nil
},
},
{
Name: "download",
Usage: "download a file from a NIP-34 repository",
ArgsUsage: "<repository> <path>",
Flags: []cli.Flag{
&cli.StringFlag{
Name: "output",
Aliases: []string{"O"},
Usage: "output path (use '-' for stdout)",
},
&cli.StringFlag{
Name: "ref",
Aliases: []string{"r"},
Usage: "git ref/tag/branch/commit to read from",
},
},
Action: func(ctx context.Context, c *cli.Command) error {
args := c.Args()
if args.Len() < 2 {
return fmt.Errorf("missing repository and path")
}
repo := args.Get(0)
path := args.Get(1)
outputPath := c.String("output")
ref := strings.TrimSpace(c.String("ref"))
if outputPath == "" {
cleaned := strings.TrimRight(path, "/")
base := filepath.Base(cleaned)
if base == "." || base == "/" || base == "" {
return fmt.Errorf("cannot determine output filename from path '%s', use --output", path)
}
outputPath = base
}
if outputPath != "-" {
if fi, err := os.Stat(outputPath); err == nil && fi.IsDir() {
return fmt.Errorf("output path '%s' is a directory", outputPath)
}
}
var gitURLs []string
if strings.HasPrefix(repo, "http://") || strings.HasPrefix(repo, "https://") {
gitURLs = []string{strings.TrimRight(repo, "/")}
} else {
owner, identifier, relayHints, err := parseRepositoryAddress(ctx, repo)
if err != nil {
return fmt.Errorf("failed to parse repository address '%s': %w", repo, err)
}
repo, _, _, state, err := fetchRepositoryAndState(ctx, owner, identifier, relayHints)
if err != nil {
var stateErr *StateErr
if ref == "" || !errors.As(err, &stateErr) {
return err
}
}
if ref == "" && state != nil && state.HEAD != "" {
ref = state.HEAD
}
for _, url := range repo.Clone {
if strings.HasPrefix(url, "http") {
gitURLs = append(gitURLs, url)
}
}
}
if len(gitURLs) == 0 {
return fmt.Errorf("no HTTP git URLs found for repository")
}
var lastErr error
for _, url := range gitURLs {
if lastErr != nil {
log("%s\n", color.HiRedString(lastErr.Error()))
}
lastErr = nil
{
printUrl := color.BlueString(url)
if grasp.IsGraspURL(url) {
printUrl = color.HiYellowString(strings.Split(url, "/")[2])
}
log("attempting download from %s... ", printUrl)
}
info, err := gitnaturalapi.GetInfoRefs(url)
if err != nil {
lastErr = err
continue
}
var commitHash string
if ref == "" {
if symref, ok := info.Symrefs["HEAD"]; ok && symref != "" {
commitHash, _ = info.Refs[symref]
} else if head, ok := info.Refs["HEAD"]; ok && head != "" {
commitHash = head
} else {
lastErr = fmt.Errorf("could not resolve default ref for %s", url)
continue
}
}
if gitHashRe.MatchString(ref) {
commitHash = ref
} else if strings.HasPrefix(ref, "refs/") {
if ch, ok := info.Refs[ref]; ok {
commitHash = ch
}
} else {
if ch, ok := info.Refs["refs/heads/"+ref]; ok {
commitHash = ch
} else if ch, ok := info.Refs["refs/tags/"+ref]; ok {
commitHash = ch
} else if sr, ok := info.Symrefs[ref]; ok && ch != "" {
commitHash, _ = info.Refs[sr]
}
}
if commitHash == "" {
lastErr = fmt.Errorf("couldn't get a commit hash for ref '%s'", ref)
continue
}
if !gitHashRe.MatchString(commitHash) {
lastErr = fmt.Errorf("couldn't invalid commit hash for ref '%s': '%s'", ref, commitHash)
continue
}
entry, err := gitnaturalapi.GetObjectByPath(url, commitHash, path)
if err != nil {
lastErr = err
continue
}
if entry == nil {
lastErr = fmt.Errorf("path '%s' not found", path)
continue
}
if entry.IsDir {
lastErr = fmt.Errorf("path '%s' is a directory", path)
continue
}
obj, err := gitnaturalapi.GetObject(url, entry.Hash)
if err != nil {
lastErr = fmt.Errorf("download error: %s", err)
continue
}
if obj == nil {
lastErr = fmt.Errorf("object for '%s' not found", path)
continue
}
if obj.Type != gitnaturalapi.ObjectTypeBlob {
lastErr = fmt.Errorf("object at '%s' is not a file", path)
continue
}
if outputPath == "-" {
if _, err = os.Stdout.Write(obj.Data); err != nil {
log("\nprinted object %s to stdout\n", color.CyanString(obj.Hash))
return err
}
}
if err := os.WriteFile(outputPath, obj.Data, 0644); err != nil {
return fmt.Errorf("failed to write %s: %w", outputPath, err)
}
log("\nsaved object %s to %s\n", color.CyanString(obj.Hash), color.GreenString(outputPath))
return nil
}
if lastErr != nil {
log("%s\n", color.HiRedString(lastErr.Error()))
}
return fmt.Errorf("failed to download '%s' from '%s'", path, repo)
},
},
{
Name: "push",
Usage: "push git changes",
@@ -1769,7 +1956,10 @@ func ensureGitRepositoryMaintainer(ctx context.Context, kr nostr.Keyer, repo nip
return pubkey, nil
}
var patchPrefixRe = regexp.MustCompile(`(?i)^\[patch[^\]]*\]\s*`)
var (
patchPrefixRe = regexp.MustCompile(`(?i)^\[patch[^\]]*\]\s*`)
gitHashRe = regexp.MustCompile(`^[0-9a-f]{7,64}$`)
)
func patchSubjectPreview(evt nostr.RelayEvent, maxChars int) string {
for _, line := range strings.Split(evt.Content, "\n") {

2
go.mod
View File

@@ -3,7 +3,7 @@ module github.com/fiatjaf/nak
go 1.25
require (
fiatjaf.com/nostr v0.0.0-20260320232724-e675f04bd29a
fiatjaf.com/nostr v0.0.0-20260326203601-3acfbbca0aea
github.com/AlecAivazis/survey/v2 v2.3.7
github.com/bep/debounce v1.2.1
github.com/btcsuite/btcd/btcec/v2 v2.3.6

4
go.sum
View File

@@ -1,7 +1,7 @@
fiatjaf.com/lib v0.3.6 h1:GRZNSxHI2EWdjSKVuzaT+c0aifLDtS16SzkeJaHyJfY=
fiatjaf.com/lib v0.3.6/go.mod h1:UlHaZvPHj25PtKLh9GjZkUHRmQ2xZ8Jkoa4VRaLeeQ8=
fiatjaf.com/nostr v0.0.0-20260320232724-e675f04bd29a h1:lor1LcOjMUNZi5hafyXMmTz5J2kTrvS5I0hZMy3jOuU=
fiatjaf.com/nostr v0.0.0-20260320232724-e675f04bd29a/go.mod h1:iRKV8eYKzePA30MdbaYBpAv8pYQ6to8rDr3W+R2hJzM=
fiatjaf.com/nostr v0.0.0-20260326203601-3acfbbca0aea h1:NvAnNbYjz7oSsmhMi1BTV02F/RIb//39W1hUTvpUbEU=
fiatjaf.com/nostr v0.0.0-20260326203601-3acfbbca0aea/go.mod h1:ue7yw0zHfZj23Ml2kVSdBx0ENEaZiuvGxs/8VEN93FU=
github.com/AlecAivazis/survey/v2 v2.3.7 h1:6I/u8FvytdGsgonrYsVn2t8t4QiRnh6QSTqkkhIiSjQ=
github.com/AlecAivazis/survey/v2 v2.3.7/go.mod h1:xUTIdE4KCOIjsBAE1JYsUPoCqYdZ1reCfTwbto0Fduo=
github.com/FastFilter/xorfilter v0.2.1 h1:lbdeLG9BdpquK64ZsleBS8B4xO/QW1IM0gMzF7KaBKc=