package repomanager import ( "bufio" "bytes" "context" "errors" "fmt" "os" "os/exec" "path/filepath" "slices" "strings" "github.com/bluesky-social/indigo/atproto/syntax" "github.com/go-git/go-git/v5" "github.com/go-git/go-git/v5/config" "github.com/go-git/go-git/v5/plumbing/object" kgit "tangled.org/core/knotserver/git" "tangled.org/core/types" ) // RepoManager manages a `sh.tangled.repo` record with its git context. // It can be used to efficiently fetch the filetree of the repository. type RepoManager struct { repoDir string // TODO: it would be nice if RepoManager can be configured with different // strategies: // - use db as an only source for repo records // - use atproto if record doesn't exist from the db // - always use atproto // hmm do we need `RepoStore` interface? // now `DbRepoStore` and `AtprotoRepoStore` can implement both. // all `RepoStore` objects will hold `KnotStore` interface, so they can // source the knot store if needed. // but now we can't do complex queries like "get repo with issue count" // that kind of queries will be done directly from `appview.DB` struct // is graphql better tech for atproto? } func New(repoDir string) RepoManager { return RepoManager{ repoDir: repoDir, } } // TODO: RepoManager can return file tree from repoAt & rev // It will start syncing the repository if doesn't exist // RegisterRepo starts sparse-syncing repository with paths func (m *RepoManager) RegisterRepo(ctx context.Context, repoAt syntax.ATURI, paths []string) error { repoPath := m.repoPath(repoAt) exist, err := isDir(repoPath) if err != nil { return fmt.Errorf("checking dir info: %w", err) } var sparsePaths []string if !exist { // init bare git repo repo, err := git.PlainInit(repoPath, true) if err != nil { return fmt.Errorf("initializing repo: %w", err) } _, err = repo.CreateRemote(&config.RemoteConfig{ Name: "origin", URLs: []string{m.repoCloneUrl(repoAt)}, }) if err != nil { return fmt.Errorf("configuring repo remote: %w", err) } } else { // get sparse-checkout list sparsePaths, err = func(path string) ([]string, error) { var stdout bytes.Buffer listCmd := exec.Command("git", "-C", path, "sparse-checkout", "list") listCmd.Stdout = &stdout if err := listCmd.Run(); err != nil { return nil, err } var sparseList []string scanner := bufio.NewScanner(&stdout) for scanner.Scan() { line := strings.TrimSpace(scanner.Text()) if line == "" { continue } sparseList = append(sparseList, line) } if err := scanner.Err(); err != nil { return nil, fmt.Errorf("scanning stdout: %w", err) } return sparseList, nil }(repoPath) if err != nil { return fmt.Errorf("parsing sparse-checkout list: %w", err) } // add paths to sparse-checkout list for _, path := range paths { sparsePaths = append(sparsePaths, path) } sparsePaths = slices.Collect(slices.Values(sparsePaths)) } // set sparse-checkout list args := append([]string{"-C", repoPath, "sparse-checkout", "set", "--no-cone"}, sparsePaths...) if err := exec.Command("git", args...).Run(); err != nil { return fmt.Errorf("setting sparse-checkout list: %w", err) } return nil } // SyncRepo sparse-fetch specific rev of the repo func (m *RepoManager) SyncRepo(ctx context.Context, repo syntax.ATURI, rev string) error { // TODO: fetch repo with rev. panic("unimplemented") } func (m *RepoManager) Open(repo syntax.ATURI, rev string) (*kgit.GitRepo, error) { // TODO: don't depend on knot/git return kgit.Open(m.repoPath(repo), rev) } func (m *RepoManager) FileTree(ctx context.Context, repo syntax.ATURI, rev, path string) ([]types.NiceTree, error) { if err := m.SyncRepo(ctx, repo, rev); err != nil { return nil, fmt.Errorf("syncing git repo") } gr, err := m.Open(repo, rev) if err != nil { return nil, err } dir, err := gr.FileTree(ctx, path) if err != nil { if errors.Is(err, object.ErrDirectoryNotFound) { return nil, nil } return nil, fmt.Errorf("loading file tree: %w", err) } return dir, err } func (m *RepoManager) repoPath(repo syntax.ATURI) string { return filepath.Join( m.repoDir, repo.Authority().String(), repo.Collection().String(), repo.RecordKey().String(), ) } func (m *RepoManager) repoCloneUrl(repo syntax.ATURI) string { // 1. get repo & knot models from db. fetch it if doesn't exist // 2. construct https clone url panic("unimplemented") } func isDir(path string) (bool, error) { info, err := os.Stat(path) if err == nil && info.IsDir() { return true, nil } if os.IsNotExist(err) { return false, nil } return false, err }