| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257 |
- package git
- /*
- worktree.go
- Index and working tree mutations: staging, unstaging, discarding and
- committing.
- The GitApp UI follows the GitHub Desktop model where the user ticks the files
- that belong in the next commit rather than maintaining a long lived staging
- area, so Commit accepts an explicit file list and stages it immediately
- before writing the commit object.
- */
- import (
- "errors"
- "os"
- "path/filepath"
- "strings"
- "time"
- gogit "github.com/go-git/go-git/v5"
- "github.com/go-git/go-git/v5/plumbing/object"
- )
- // Add stages the given repo-relative paths. Deleted paths are removed from the
- // index, which is what go-git's Add already does for a missing file.
- func (m *Manager) Add(realpath string, files []string) error {
- _, tree, err := m.worktree(realpath)
- if err != nil {
- return err
- }
- for _, file := range files {
- cleaned, err := cleanRepoPath(file)
- if err != nil {
- return err
- }
- if _, err := tree.Add(cleaned); err != nil {
- return errors.New("cannot stage " + cleaned + ": " + err.Error())
- }
- }
- return nil
- }
- // AddAll stages every change in the working tree, equivalent to `git add -A`.
- func (m *Manager) AddAll(realpath string) error {
- _, tree, err := m.worktree(realpath)
- if err != nil {
- return err
- }
- return tree.AddWithOptions(&gogit.AddOptions{All: true})
- }
- // Unstage removes the given paths from the index while leaving the working tree
- // untouched, equivalent to `git restore --staged`.
- func (m *Manager) Unstage(realpath string, files []string) error {
- _, tree, err := m.worktree(realpath)
- if err != nil {
- return err
- }
- cleanedFiles, err := cleanRepoPaths(files)
- if err != nil {
- return err
- }
- if len(cleanedFiles) == 0 {
- return errors.New("no files given to unstage")
- }
- return tree.Restore(&gogit.RestoreOptions{
- Staged: true,
- Files: cleanedFiles,
- })
- }
- // Discard throws away the working tree changes of the given paths, restoring
- // them from the index (`git restore`). Untracked files have nothing to restore
- // from, so they are deleted instead — the same thing GitHub Desktop's "Discard
- // changes" does.
- func (m *Manager) Discard(realpath string, files []string) error {
- _, tree, err := m.worktree(realpath)
- if err != nil {
- return err
- }
- cleanedFiles, err := cleanRepoPaths(files)
- if err != nil {
- return err
- }
- if len(cleanedFiles) == 0 {
- return errors.New("no files given to discard")
- }
- status, err := tree.Status()
- if err != nil {
- return err
- }
- restorable := []string{}
- repoRoot := tree.Filesystem.Root()
- for _, file := range cleanedFiles {
- if fileStatus, ok := status[file]; ok && fileStatus.Worktree == gogit.Untracked {
- //Never tracked: removing the file is the only possible "discard"
- if err := os.Remove(filepath.Join(repoRoot, filepath.FromSlash(file))); err != nil && !os.IsNotExist(err) {
- return err
- }
- continue
- }
- restorable = append(restorable, file)
- }
- if len(restorable) == 0 {
- return nil
- }
- return tree.Restore(&gogit.RestoreOptions{
- Staged: true,
- Worktree: true,
- Files: restorable,
- })
- }
- // Commit stages req.Files (or every tracked change when req.All is set) and
- // writes a commit. The new commit hash is returned.
- func (m *Manager) Commit(realpath string, req *CommitRequest) (string, error) {
- if req == nil || strings.TrimSpace(req.Message) == "" {
- return "", errors.New("commit message cannot be empty")
- }
- repo, tree, err := m.worktree(realpath)
- if err != nil {
- return "", err
- }
- if req.All {
- if err := tree.AddWithOptions(&gogit.AddOptions{All: true}); err != nil {
- return "", err
- }
- } else if len(req.Files) > 0 {
- if err := m.Add(realpath, req.Files); err != nil {
- return "", err
- }
- }
- signature, err := resolveSignature(repo, req.Name, req.Email)
- if err != nil {
- return "", err
- }
- hash, err := tree.Commit(req.Message, &gogit.CommitOptions{
- Author: signature,
- Committer: signature,
- })
- if err != nil {
- if errors.Is(err, gogit.ErrEmptyCommit) {
- return "", errors.New("nothing to commit — select at least one changed file")
- }
- return "", err
- }
- return hash.String(), nil
- }
- // resolveSignature builds the author signature, preferring the values passed by
- // the caller and falling back to the repository's own git config. go-git
- // refuses to commit without an author, so an explicit error beats its generic
- // validation message.
- func resolveSignature(repo *gogit.Repository, name string, email string) (*object.Signature, error) {
- name = strings.TrimSpace(name)
- email = strings.TrimSpace(email)
- if name == "" || email == "" {
- //Only the repository's own config is consulted. Reading the host's
- //global gitconfig would leak the identity of whoever runs the ArozOS
- //process into every user's commits.
- if cfg, err := repo.Config(); err == nil {
- if name == "" {
- name = cfg.User.Name
- }
- if email == "" {
- email = cfg.User.Email
- }
- }
- }
- if name == "" {
- return nil, errors.New("commit author name is required — set it in GitApp settings")
- }
- if email == "" {
- //An address is mandatory in the commit object format; synthesise a
- //local one rather than blocking the commit.
- email = sanitiseLocalEmail(name)
- }
- return &object.Signature{
- Name: name,
- Email: email,
- When: time.Now(),
- }, nil
- }
- // sanitiseLocalEmail turns an author name into a usable placeholder address for
- // users who never configured one.
- func sanitiseLocalEmail(name string) string {
- local := strings.Map(func(r rune) rune {
- switch {
- case r >= 'a' && r <= 'z', r >= '0' && r <= '9', r == '.', r == '-', r == '_':
- return r
- case r >= 'A' && r <= 'Z':
- return r + 32
- default:
- return -1
- }
- }, name)
- if local == "" {
- local = "user"
- }
- return local + "@arozos.local"
- }
- // cleanRepoPaths normalises a list of repo-relative paths, dropping empties.
- func cleanRepoPaths(files []string) ([]string, error) {
- results := []string{}
- for _, file := range files {
- if strings.TrimSpace(file) == "" {
- continue
- }
- cleaned, err := cleanRepoPath(file)
- if err != nil {
- return nil, err
- }
- results = append(results, cleaned)
- }
- return results, nil
- }
- // cleanRepoPath validates that a path stays inside the repository. Paths come
- // from the browser, so a "../" escape must never reach the filesystem.
- func cleanRepoPath(file string) (string, error) {
- cleaned := filepath.ToSlash(filepath.Clean(strings.TrimSpace(file)))
- cleaned = strings.TrimPrefix(cleaned, "./")
- if cleaned == "" || cleaned == "." {
- return "", errors.New("empty file path")
- }
- if strings.HasPrefix(cleaned, "../") || cleaned == ".." || strings.HasPrefix(cleaned, "/") {
- return "", errors.New("path escapes the repository: " + file)
- }
- if filepath.IsAbs(cleaned) {
- return "", errors.New("absolute paths are not accepted: " + file)
- }
- return cleaned, nil
- }
|