Files
anders 4df87cbf9a Phase 2-6: Git sync, sharing, 2FA, AI integration
- Git: init, commit, log, diff, restore, remotes, push/pull
- Auto-commit on every file save
- Sharing: share/unshare files with other users (ro/rw)
- Shared documents view in sidebar
- 2FA: TOTP setup/verify/disable, enforced at login
- AI: verify spec endpoint (LiteLLM), generate (summarize/prompt/expand)
- Light/dark theme with CSS variables
- File delete (recursive for folders)
- Admin panel + preferences panel
- File creation timestamp display
2026-05-22 19:53:24 +02:00

177 lines
4.9 KiB
Go

package git
import (
"fmt"
"os"
"os/exec"
"path/filepath"
"strings"
"time"
)
// InitRepo initializes a git repo in the user's file directory if not already initialized.
func InitRepo(dataDir, userID string) error {
repoDir := filepath.Join(dataDir, "files", userID)
gitDir := filepath.Join(repoDir, ".git")
if _, err := os.Stat(gitDir); err == nil {
return nil // already initialized
}
return run(repoDir, "git", "init")
}
// Commit stages all changes and commits with the given message.
func Commit(dataDir, userID, message string) error {
repoDir := filepath.Join(dataDir, "files", userID)
if err := run(repoDir, "git", "add", "-A"); err != nil {
return err
}
// Check if there's anything to commit
out, err := output(repoDir, "git", "status", "--porcelain")
if err != nil || strings.TrimSpace(out) == "" {
return nil // nothing to commit
}
return runEnv(repoDir, []string{
"GIT_AUTHOR_NAME=MarkdownHub",
"GIT_AUTHOR_EMAIL=user@markdownhub",
"GIT_COMMITTER_NAME=MarkdownHub",
"GIT_COMMITTER_EMAIL=user@markdownhub",
}, "git", "commit", "-m", message)
}
// AutoCommit commits with an auto-generated message including timestamp.
func AutoCommit(dataDir, userID, filename string) error {
msg := fmt.Sprintf("Update %s (%s)", filename, time.Now().Format("2006-01-02 15:04"))
return Commit(dataDir, userID, msg)
}
// Log returns recent commit history for a file.
func Log(dataDir, userID, relPath string, limit int) ([]CommitInfo, error) {
repoDir := filepath.Join(dataDir, "files", userID)
args := []string{"log", "--format=%H|%ai|%s", fmt.Sprintf("-n%d", limit)}
if relPath != "" {
args = append(args, "--", relPath)
}
out, err := output(repoDir, "git", args...)
if err != nil {
return nil, err
}
var commits []CommitInfo
for _, line := range strings.Split(strings.TrimSpace(out), "\n") {
if line == "" {
continue
}
parts := strings.SplitN(line, "|", 3)
if len(parts) == 3 {
commits = append(commits, CommitInfo{
Hash: parts[0],
Date: parts[1],
Message: parts[2],
})
}
}
return commits, nil
}
// Diff returns the diff for a specific commit.
func Diff(dataDir, userID, commitHash string) (string, error) {
repoDir := filepath.Join(dataDir, "files", userID)
return output(repoDir, "git", "show", "--stat", "--patch", commitHash)
}
// Restore restores a file to a specific commit version.
func Restore(dataDir, userID, relPath, commitHash string) error {
repoDir := filepath.Join(dataDir, "files", userID)
return run(repoDir, "git", "checkout", commitHash, "--", relPath)
}
// AddRemote adds a named remote to the user's repo.
func AddRemote(dataDir, userID, name, url string) error {
repoDir := filepath.Join(dataDir, "files", userID)
// Remove if exists, then add
run(repoDir, "git", "remote", "remove", name)
return run(repoDir, "git", "remote", "add", name, url)
}
// Push pushes to a named remote.
func Push(dataDir, userID, remoteName, branch string) error {
repoDir := filepath.Join(dataDir, "files", userID)
if branch == "" {
branch = "master"
}
return run(repoDir, "git", "push", remoteName, branch)
}
// Pull pulls from a named remote.
func Pull(dataDir, userID, remoteName, branch string) error {
repoDir := filepath.Join(dataDir, "files", userID)
if branch == "" {
branch = "master"
}
return run(repoDir, "git", "pull", "--rebase", remoteName, branch)
}
// ListRemotes returns configured remotes.
func ListRemotes(dataDir, userID string) ([]RemoteInfo, error) {
repoDir := filepath.Join(dataDir, "files", userID)
out, err := output(repoDir, "git", "remote", "-v")
if err != nil {
return nil, err
}
seen := map[string]bool{}
var remotes []RemoteInfo
for _, line := range strings.Split(out, "\n") {
parts := strings.Fields(line)
if len(parts) >= 2 && !seen[parts[0]] {
seen[parts[0]] = true
remotes = append(remotes, RemoteInfo{Name: parts[0], URL: parts[1]})
}
}
return remotes, nil
}
// Status returns dirty file count.
func Status(dataDir, userID string) (int, error) {
repoDir := filepath.Join(dataDir, "files", userID)
out, err := output(repoDir, "git", "status", "--porcelain")
if err != nil {
return 0, err
}
if strings.TrimSpace(out) == "" {
return 0, nil
}
return len(strings.Split(strings.TrimSpace(out), "\n")), nil
}
type CommitInfo struct {
Hash string `json:"hash"`
Date string `json:"date"`
Message string `json:"message"`
}
type RemoteInfo struct {
Name string `json:"name"`
URL string `json:"url"`
}
func run(dir string, name string, args ...string) error {
cmd := exec.Command(name, args...)
cmd.Dir = dir
cmd.Stdout = nil
cmd.Stderr = nil
return cmd.Run()
}
func runEnv(dir string, env []string, name string, args ...string) error {
cmd := exec.Command(name, args...)
cmd.Dir = dir
cmd.Env = append(os.Environ(), env...)
return cmd.Run()
}
func output(dir string, name string, args ...string) (string, error) {
cmd := exec.Command(name, args...)
cmd.Dir = dir
out, err := cmd.Output()
return string(out), err
}