mirror of
https://github.com/yarlson/lnk.git
synced 2025-09-08 18:50:39 +02:00
feat: implement phase 1 sync functionality
- Add lnk status command to show repository sync status - Add lnk push command for smart staging, committing, and pushing - Add lnk pull command with automatic symlink restoration - Add comprehensive sync functionality to git and core packages - Add 5 new integration tests for sync commands (17 total tests) - Update README with sync workflow examples and documentation - Support commits ahead/behind tracking relative to remote - Implement change detection to avoid empty commits - Add graceful error handling for missing remotes Closes: Phase 1 sync implementation
This commit is contained in:
@@ -167,3 +167,177 @@ func (l *Lnk) Remove(filePath string) error {
|
||||
func (l *Lnk) GetCommits() ([]string, error) {
|
||||
return l.git.GetCommits()
|
||||
}
|
||||
|
||||
// StatusInfo contains repository sync status information
|
||||
type StatusInfo struct {
|
||||
Ahead int
|
||||
Behind int
|
||||
Remote string
|
||||
}
|
||||
|
||||
// Status returns the repository sync status
|
||||
func (l *Lnk) Status() (*StatusInfo, error) {
|
||||
// Check if repository is initialized
|
||||
if !l.git.IsGitRepository() {
|
||||
return nil, fmt.Errorf("lnk repository not initialized - run 'lnk init' first")
|
||||
}
|
||||
|
||||
gitStatus, err := l.git.GetStatus()
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to get repository status: %w", err)
|
||||
}
|
||||
|
||||
return &StatusInfo{
|
||||
Ahead: gitStatus.Ahead,
|
||||
Behind: gitStatus.Behind,
|
||||
Remote: gitStatus.Remote,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// Push stages all changes and creates a sync commit, then pushes to remote
|
||||
func (l *Lnk) Push(message string) error {
|
||||
// Check if repository is initialized
|
||||
if !l.git.IsGitRepository() {
|
||||
return fmt.Errorf("lnk repository not initialized - run 'lnk init' first")
|
||||
}
|
||||
|
||||
// Check if there are any changes
|
||||
hasChanges, err := l.git.HasChanges()
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to check for changes: %w", err)
|
||||
}
|
||||
|
||||
if hasChanges {
|
||||
// Stage all changes
|
||||
if err := l.git.AddAll(); err != nil {
|
||||
return fmt.Errorf("failed to stage changes: %w", err)
|
||||
}
|
||||
|
||||
// Create a sync commit
|
||||
if err := l.git.Commit(message); err != nil {
|
||||
return fmt.Errorf("failed to commit changes: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
// Push to remote (this will be a no-op in tests since we don't have real remotes)
|
||||
// In real usage, this would push to the actual remote repository
|
||||
if err := l.git.Push(); err != nil {
|
||||
return fmt.Errorf("failed to push to remote: %w", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// Pull fetches changes from remote and restores symlinks as needed
|
||||
func (l *Lnk) Pull() ([]string, error) {
|
||||
// Check if repository is initialized
|
||||
if !l.git.IsGitRepository() {
|
||||
return nil, fmt.Errorf("lnk repository not initialized - run 'lnk init' first")
|
||||
}
|
||||
|
||||
// Pull changes from remote (this will be a no-op in tests since we don't have real remotes)
|
||||
if err := l.git.Pull(); err != nil {
|
||||
return nil, fmt.Errorf("failed to pull from remote: %w", err)
|
||||
}
|
||||
|
||||
// Find all managed files in the repository and restore symlinks
|
||||
restored, err := l.RestoreSymlinks()
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to restore symlinks: %w", err)
|
||||
}
|
||||
|
||||
return restored, nil
|
||||
}
|
||||
|
||||
// RestoreSymlinks finds all files in the repository and ensures they have proper symlinks
|
||||
func (l *Lnk) RestoreSymlinks() ([]string, error) {
|
||||
var restored []string
|
||||
|
||||
// Read all files in the repository
|
||||
entries, err := os.ReadDir(l.repoPath)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to read repository directory: %w", err)
|
||||
}
|
||||
|
||||
for _, entry := range entries {
|
||||
// Skip hidden files and directories (like .git)
|
||||
if entry.Name()[0] == '.' {
|
||||
continue
|
||||
}
|
||||
|
||||
// Skip directories
|
||||
if entry.IsDir() {
|
||||
continue
|
||||
}
|
||||
|
||||
filename := entry.Name()
|
||||
repoFile := filepath.Join(l.repoPath, filename)
|
||||
|
||||
// Determine where the symlink should be
|
||||
// For config files, we'll place them in the user's home directory
|
||||
homeDir, err := os.UserHomeDir()
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to get home directory: %w", err)
|
||||
}
|
||||
|
||||
symlinkPath := filepath.Join(homeDir, filename)
|
||||
|
||||
// Check if symlink already exists and is correct
|
||||
if l.isValidSymlink(symlinkPath, repoFile) {
|
||||
continue
|
||||
}
|
||||
|
||||
// Remove existing file/symlink if it exists
|
||||
if _, err := os.Lstat(symlinkPath); err == nil {
|
||||
if err := os.Remove(symlinkPath); err != nil {
|
||||
return nil, fmt.Errorf("failed to remove existing file %s: %w", symlinkPath, err)
|
||||
}
|
||||
}
|
||||
|
||||
// Create symlink
|
||||
if err := l.fs.CreateSymlink(repoFile, symlinkPath); err != nil {
|
||||
return nil, fmt.Errorf("failed to create symlink for %s: %w", filename, err)
|
||||
}
|
||||
|
||||
restored = append(restored, filename)
|
||||
}
|
||||
|
||||
return restored, nil
|
||||
}
|
||||
|
||||
// isValidSymlink checks if the given path is a symlink pointing to the expected target
|
||||
func (l *Lnk) isValidSymlink(symlinkPath, expectedTarget string) bool {
|
||||
info, err := os.Lstat(symlinkPath)
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
|
||||
// Check if it's a symlink
|
||||
if info.Mode()&os.ModeSymlink == 0 {
|
||||
return false
|
||||
}
|
||||
|
||||
// Check if it points to the correct target
|
||||
target, err := os.Readlink(symlinkPath)
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
|
||||
// Convert relative path to absolute if needed
|
||||
if !filepath.IsAbs(target) {
|
||||
target = filepath.Join(filepath.Dir(symlinkPath), target)
|
||||
}
|
||||
|
||||
// Clean both paths for comparison
|
||||
targetAbs, err := filepath.Abs(target)
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
|
||||
expectedAbs, err := filepath.Abs(expectedTarget)
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
|
||||
return targetAbs == expectedAbs
|
||||
}
|
||||
|
@@ -135,7 +135,7 @@ func (g *Git) AddAndCommit(filename, message string) error {
|
||||
}
|
||||
|
||||
// Commit the changes
|
||||
if err := g.commit(message); err != nil {
|
||||
if err := g.Commit(message); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
@@ -150,7 +150,7 @@ func (g *Git) RemoveAndCommit(filename, message string) error {
|
||||
}
|
||||
|
||||
// Commit the changes
|
||||
if err := g.commit(message); err != nil {
|
||||
if err := g.Commit(message); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
@@ -183,8 +183,8 @@ func (g *Git) remove(filename string) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
// commit creates a commit with the given message
|
||||
func (g *Git) commit(message string) error {
|
||||
// Commit creates a commit with the given message
|
||||
func (g *Git) Commit(message string) error {
|
||||
// Configure git user if not already configured
|
||||
if err := g.ensureGitConfig(); err != nil {
|
||||
return err
|
||||
@@ -258,3 +258,190 @@ func (g *Git) GetCommits() ([]string, error) {
|
||||
|
||||
return commits, nil
|
||||
}
|
||||
|
||||
// GetRemoteInfo returns information about the default remote
|
||||
func (g *Git) GetRemoteInfo() (string, error) {
|
||||
// First try to get origin remote
|
||||
url, err := g.getRemoteURL("origin")
|
||||
if err != nil {
|
||||
// If origin doesn't exist, try to get any remote
|
||||
cmd := exec.Command("git", "remote")
|
||||
cmd.Dir = g.repoPath
|
||||
|
||||
output, err := cmd.Output()
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("failed to list remotes: %w", err)
|
||||
}
|
||||
|
||||
remotes := strings.Split(strings.TrimSpace(string(output)), "\n")
|
||||
if len(remotes) == 0 || remotes[0] == "" {
|
||||
return "", fmt.Errorf("no remote configured")
|
||||
}
|
||||
|
||||
// Use the first remote
|
||||
url, err = g.getRemoteURL(remotes[0])
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("failed to get remote URL: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
return url, nil
|
||||
}
|
||||
|
||||
// StatusInfo contains repository status information
|
||||
type StatusInfo struct {
|
||||
Ahead int
|
||||
Behind int
|
||||
Remote string
|
||||
}
|
||||
|
||||
// GetStatus returns the repository status relative to remote
|
||||
func (g *Git) GetStatus() (*StatusInfo, error) {
|
||||
// Check if we have a remote
|
||||
_, err := g.GetRemoteInfo()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// Get the remote tracking branch
|
||||
cmd := exec.Command("git", "rev-parse", "--abbrev-ref", "--symbolic-full-name", "@{u}")
|
||||
cmd.Dir = g.repoPath
|
||||
|
||||
output, err := cmd.Output()
|
||||
if err != nil {
|
||||
// No upstream branch set, assume origin/main
|
||||
remoteBranch := "origin/main"
|
||||
return &StatusInfo{
|
||||
Ahead: g.getAheadCount(remoteBranch),
|
||||
Behind: 0, // Can't be behind if no upstream
|
||||
Remote: remoteBranch,
|
||||
}, nil
|
||||
}
|
||||
|
||||
remoteBranch := strings.TrimSpace(string(output))
|
||||
|
||||
return &StatusInfo{
|
||||
Ahead: g.getAheadCount(remoteBranch),
|
||||
Behind: g.getBehindCount(remoteBranch),
|
||||
Remote: remoteBranch,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// getAheadCount returns how many commits ahead of remote
|
||||
func (g *Git) getAheadCount(remoteBranch string) int {
|
||||
cmd := exec.Command("git", "rev-list", "--count", fmt.Sprintf("%s..HEAD", remoteBranch))
|
||||
cmd.Dir = g.repoPath
|
||||
|
||||
output, err := cmd.Output()
|
||||
if err != nil {
|
||||
// If remote branch doesn't exist, count all local commits
|
||||
cmd = exec.Command("git", "rev-list", "--count", "HEAD")
|
||||
cmd.Dir = g.repoPath
|
||||
|
||||
output, err = cmd.Output()
|
||||
if err != nil {
|
||||
return 0
|
||||
}
|
||||
}
|
||||
|
||||
count := strings.TrimSpace(string(output))
|
||||
if count == "" {
|
||||
return 0
|
||||
}
|
||||
|
||||
// Convert to int
|
||||
var ahead int
|
||||
if _, err := fmt.Sscanf(count, "%d", &ahead); err != nil {
|
||||
return 0
|
||||
}
|
||||
|
||||
return ahead
|
||||
}
|
||||
|
||||
// getBehindCount returns how many commits behind remote
|
||||
func (g *Git) getBehindCount(remoteBranch string) int {
|
||||
cmd := exec.Command("git", "rev-list", "--count", fmt.Sprintf("HEAD..%s", remoteBranch))
|
||||
cmd.Dir = g.repoPath
|
||||
|
||||
output, err := cmd.Output()
|
||||
if err != nil {
|
||||
return 0
|
||||
}
|
||||
|
||||
count := strings.TrimSpace(string(output))
|
||||
if count == "" {
|
||||
return 0
|
||||
}
|
||||
|
||||
// Convert to int
|
||||
var behind int
|
||||
if _, err := fmt.Sscanf(count, "%d", &behind); err != nil {
|
||||
return 0
|
||||
}
|
||||
|
||||
return behind
|
||||
}
|
||||
|
||||
// HasChanges checks if there are uncommitted changes
|
||||
func (g *Git) HasChanges() (bool, error) {
|
||||
cmd := exec.Command("git", "status", "--porcelain")
|
||||
cmd.Dir = g.repoPath
|
||||
|
||||
output, err := cmd.Output()
|
||||
if err != nil {
|
||||
return false, fmt.Errorf("git status failed: %w", err)
|
||||
}
|
||||
|
||||
return len(strings.TrimSpace(string(output))) > 0, nil
|
||||
}
|
||||
|
||||
// AddAll stages all changes in the repository
|
||||
func (g *Git) AddAll() error {
|
||||
cmd := exec.Command("git", "add", "-A")
|
||||
cmd.Dir = g.repoPath
|
||||
|
||||
output, err := cmd.CombinedOutput()
|
||||
if err != nil {
|
||||
return fmt.Errorf("git add failed: %w\nOutput: %s", err, string(output))
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// Push pushes changes to remote
|
||||
func (g *Git) Push() error {
|
||||
// First ensure we have a remote configured
|
||||
_, err := g.GetRemoteInfo()
|
||||
if err != nil {
|
||||
return fmt.Errorf("cannot push: %w", err)
|
||||
}
|
||||
|
||||
cmd := exec.Command("git", "push", "-u", "origin", "main")
|
||||
cmd.Dir = g.repoPath
|
||||
|
||||
output, err := cmd.CombinedOutput()
|
||||
if err != nil {
|
||||
return fmt.Errorf("git push failed: %w\nOutput: %s", err, string(output))
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// Pull pulls changes from remote
|
||||
func (g *Git) Pull() error {
|
||||
// First ensure we have a remote configured
|
||||
_, err := g.GetRemoteInfo()
|
||||
if err != nil {
|
||||
return fmt.Errorf("cannot pull: %w", err)
|
||||
}
|
||||
|
||||
cmd := exec.Command("git", "pull", "origin", "main")
|
||||
cmd.Dir = g.repoPath
|
||||
|
||||
output, err := cmd.CombinedOutput()
|
||||
if err != nil {
|
||||
return fmt.Errorf("git pull failed: %w\nOutput: %s", err, string(output))
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
Reference in New Issue
Block a user