mirror of
https://github.com/yarlson/lnk.git
synced 2025-08-31 18:01:41 +02:00
fix(status): detect and report uncommitted changes in repository status, fixes #5
This commit is contained in:
@@ -3,6 +3,7 @@ package cmd
|
|||||||
import (
|
import (
|
||||||
"bytes"
|
"bytes"
|
||||||
"os"
|
"os"
|
||||||
|
"os/exec"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
"strings"
|
"strings"
|
||||||
"testing"
|
"testing"
|
||||||
@@ -424,6 +425,49 @@ func (suite *CLITestSuite) TestSameBasenameFilesBug() {
|
|||||||
suite.Equal(contentB, string(restoredContentB), "Restored second file should have original content")
|
suite.Equal(contentB, string(restoredContentB), "Restored second file should have original content")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (suite *CLITestSuite) TestStatusDirtyRepo() {
|
||||||
|
// Initialize repository
|
||||||
|
err := suite.runCommand("init")
|
||||||
|
suite.Require().NoError(err)
|
||||||
|
suite.stdout.Reset()
|
||||||
|
|
||||||
|
// Add and commit a file
|
||||||
|
testFile := filepath.Join(suite.tempDir, "a")
|
||||||
|
err = os.WriteFile(testFile, []byte("abc"), 0644)
|
||||||
|
suite.Require().NoError(err)
|
||||||
|
|
||||||
|
err = suite.runCommand("add", testFile)
|
||||||
|
suite.Require().NoError(err)
|
||||||
|
suite.stdout.Reset()
|
||||||
|
|
||||||
|
// Add a remote so status works
|
||||||
|
lnkDir := filepath.Join(suite.tempDir, "lnk")
|
||||||
|
cmd := exec.Command("git", "remote", "add", "origin", "https://github.com/test/dotfiles.git")
|
||||||
|
cmd.Dir = lnkDir
|
||||||
|
err = cmd.Run()
|
||||||
|
suite.Require().NoError(err)
|
||||||
|
|
||||||
|
// Status should show clean but ahead
|
||||||
|
err = suite.runCommand("status")
|
||||||
|
suite.NoError(err)
|
||||||
|
output := suite.stdout.String()
|
||||||
|
suite.Contains(output, "1 commit ahead")
|
||||||
|
suite.NotContains(output, "uncommitted changes")
|
||||||
|
suite.stdout.Reset()
|
||||||
|
|
||||||
|
// Now edit the managed file (simulating the issue scenario)
|
||||||
|
err = os.WriteFile(testFile, []byte("def"), 0644)
|
||||||
|
suite.Require().NoError(err)
|
||||||
|
|
||||||
|
// Status should now detect dirty state and NOT say "up to date"
|
||||||
|
err = suite.runCommand("status")
|
||||||
|
suite.NoError(err)
|
||||||
|
output = suite.stdout.String()
|
||||||
|
suite.Contains(output, "Repository has uncommitted changes")
|
||||||
|
suite.NotContains(output, "Repository is up to date")
|
||||||
|
suite.Contains(output, "lnk push")
|
||||||
|
}
|
||||||
|
|
||||||
func TestCLISuite(t *testing.T) {
|
func TestCLISuite(t *testing.T) {
|
||||||
suite.Run(t, new(CLITestSuite))
|
suite.Run(t, new(CLITestSuite))
|
||||||
}
|
}
|
||||||
|
@@ -11,7 +11,7 @@ func newStatusCmd() *cobra.Command {
|
|||||||
return &cobra.Command{
|
return &cobra.Command{
|
||||||
Use: "status",
|
Use: "status",
|
||||||
Short: "📊 Show repository sync status",
|
Short: "📊 Show repository sync status",
|
||||||
Long: "Display how many commits ahead/behind the local repository is relative to the remote.",
|
Long: "Display how many commits ahead/behind the local repository is relative to the remote and check for uncommitted changes.",
|
||||||
SilenceUsage: true,
|
SilenceUsage: true,
|
||||||
RunE: func(cmd *cobra.Command, args []string) error {
|
RunE: func(cmd *cobra.Command, args []string) error {
|
||||||
lnk := core.NewLnk()
|
lnk := core.NewLnk()
|
||||||
@@ -20,37 +20,74 @@ func newStatusCmd() *cobra.Command {
|
|||||||
return fmt.Errorf("failed to get status: %w", err)
|
return fmt.Errorf("failed to get status: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if status.Dirty {
|
||||||
|
displayDirtyStatus(cmd, status)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
if status.Ahead == 0 && status.Behind == 0 {
|
if status.Ahead == 0 && status.Behind == 0 {
|
||||||
printf(cmd, "✅ \033[1;32mRepository is up to date\033[0m\n")
|
displayUpToDateStatus(cmd, status)
|
||||||
printf(cmd, " 📡 Synced with \033[36m%s\033[0m\n", status.Remote)
|
return nil
|
||||||
} else {
|
|
||||||
printf(cmd, "📊 \033[1mRepository Status\033[0m\n")
|
|
||||||
printf(cmd, " 📡 Remote: \033[36m%s\033[0m\n", status.Remote)
|
|
||||||
printf(cmd, "\n")
|
|
||||||
|
|
||||||
if status.Ahead > 0 {
|
|
||||||
commitText := "commit"
|
|
||||||
if status.Ahead > 1 {
|
|
||||||
commitText = "commits"
|
|
||||||
}
|
|
||||||
printf(cmd, " ⬆️ \033[1;33m%d %s ahead\033[0m - ready to push\n", status.Ahead, commitText)
|
|
||||||
}
|
|
||||||
if status.Behind > 0 {
|
|
||||||
commitText := "commit"
|
|
||||||
if status.Behind > 1 {
|
|
||||||
commitText = "commits"
|
|
||||||
}
|
|
||||||
printf(cmd, " ⬇️ \033[1;31m%d %s behind\033[0m - run \033[1mlnk pull\033[0m\n", status.Behind, commitText)
|
|
||||||
}
|
|
||||||
|
|
||||||
if status.Ahead > 0 && status.Behind == 0 {
|
|
||||||
printf(cmd, "\n💡 Run \033[1mlnk push\033[0m to sync your changes")
|
|
||||||
} else if status.Behind > 0 {
|
|
||||||
printf(cmd, "\n💡 Run \033[1mlnk pull\033[0m to get latest changes")
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
displaySyncStatus(cmd, status)
|
||||||
return nil
|
return nil
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func displayDirtyStatus(cmd *cobra.Command, status *core.StatusInfo) {
|
||||||
|
printf(cmd, "⚠️ \033[1;33mRepository has uncommitted changes\033[0m\n")
|
||||||
|
printf(cmd, " 📡 Remote: \033[36m%s\033[0m\n", status.Remote)
|
||||||
|
|
||||||
|
if status.Ahead == 0 && status.Behind == 0 {
|
||||||
|
printf(cmd, "\n💡 Run \033[1mgit add && git commit\033[0m in \033[36m~/.config/lnk\033[0m or \033[1mlnk push\033[0m to commit changes\n")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
printf(cmd, "\n")
|
||||||
|
displayAheadBehindInfo(cmd, status, true)
|
||||||
|
printf(cmd, "\n💡 Run \033[1mgit add && git commit\033[0m in \033[36m~/.config/lnk\033[0m or \033[1mlnk push\033[0m to commit changes\n")
|
||||||
|
}
|
||||||
|
|
||||||
|
func displayUpToDateStatus(cmd *cobra.Command, status *core.StatusInfo) {
|
||||||
|
printf(cmd, "✅ \033[1;32mRepository is up to date\033[0m\n")
|
||||||
|
printf(cmd, " 📡 Synced with \033[36m%s\033[0m\n", status.Remote)
|
||||||
|
}
|
||||||
|
|
||||||
|
func displaySyncStatus(cmd *cobra.Command, status *core.StatusInfo) {
|
||||||
|
printf(cmd, "📊 \033[1mRepository Status\033[0m\n")
|
||||||
|
printf(cmd, " 📡 Remote: \033[36m%s\033[0m\n", status.Remote)
|
||||||
|
printf(cmd, "\n")
|
||||||
|
|
||||||
|
displayAheadBehindInfo(cmd, status, false)
|
||||||
|
|
||||||
|
if status.Ahead > 0 && status.Behind == 0 {
|
||||||
|
printf(cmd, "\n💡 Run \033[1mlnk push\033[0m to sync your changes\n")
|
||||||
|
} else if status.Behind > 0 {
|
||||||
|
printf(cmd, "\n💡 Run \033[1mlnk pull\033[0m to get latest changes\n")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func displayAheadBehindInfo(cmd *cobra.Command, status *core.StatusInfo, isDirty bool) {
|
||||||
|
if status.Ahead > 0 {
|
||||||
|
commitText := getCommitText(status.Ahead)
|
||||||
|
if isDirty {
|
||||||
|
printf(cmd, " ⬆️ \033[1;33m%d %s ahead\033[0m (excluding uncommitted changes)\n", status.Ahead, commitText)
|
||||||
|
} else {
|
||||||
|
printf(cmd, " ⬆️ \033[1;33m%d %s ahead\033[0m - ready to push\n", status.Ahead, commitText)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if status.Behind > 0 {
|
||||||
|
commitText := getCommitText(status.Behind)
|
||||||
|
printf(cmd, " ⬇️ \033[1;31m%d %s behind\033[0m - run \033[1mlnk pull\033[0m\n", status.Behind, commitText)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func getCommitText(count int) string {
|
||||||
|
if count == 1 {
|
||||||
|
return "commit"
|
||||||
|
}
|
||||||
|
return "commits"
|
||||||
|
}
|
||||||
|
@@ -350,6 +350,7 @@ type StatusInfo struct {
|
|||||||
Ahead int
|
Ahead int
|
||||||
Behind int
|
Behind int
|
||||||
Remote string
|
Remote string
|
||||||
|
Dirty bool
|
||||||
}
|
}
|
||||||
|
|
||||||
// Status returns the repository sync status
|
// Status returns the repository sync status
|
||||||
@@ -368,6 +369,7 @@ func (l *Lnk) Status() (*StatusInfo, error) {
|
|||||||
Ahead: gitStatus.Ahead,
|
Ahead: gitStatus.Ahead,
|
||||||
Behind: gitStatus.Behind,
|
Behind: gitStatus.Behind,
|
||||||
Remote: gitStatus.Remote,
|
Remote: gitStatus.Remote,
|
||||||
|
Dirty: gitStatus.Dirty,
|
||||||
}, nil
|
}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@@ -480,6 +480,42 @@ func (suite *CoreTestSuite) TestSameBasenameSequentialAdd() {
|
|||||||
suite.Require().NoError(err, "Second .bashrc should be removable")
|
suite.Require().NoError(err, "Second .bashrc should be removable")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Test dirty repository status detection
|
||||||
|
func (suite *CoreTestSuite) TestStatusDetectsDirtyRepo() {
|
||||||
|
err := suite.lnk.Init()
|
||||||
|
suite.Require().NoError(err)
|
||||||
|
|
||||||
|
// Add and commit a file
|
||||||
|
testFile := filepath.Join(suite.tempDir, "a")
|
||||||
|
err = os.WriteFile(testFile, []byte("abc"), 0644)
|
||||||
|
suite.Require().NoError(err)
|
||||||
|
|
||||||
|
err = suite.lnk.Add(testFile)
|
||||||
|
suite.Require().NoError(err)
|
||||||
|
|
||||||
|
// Add a remote so status works
|
||||||
|
err = suite.lnk.AddRemote("origin", "https://github.com/test/dotfiles.git")
|
||||||
|
suite.Require().NoError(err)
|
||||||
|
|
||||||
|
// Check status - should be clean but ahead of remote
|
||||||
|
status, err := suite.lnk.Status()
|
||||||
|
suite.Require().NoError(err)
|
||||||
|
suite.Equal(1, status.Ahead)
|
||||||
|
suite.Equal(0, status.Behind)
|
||||||
|
suite.False(status.Dirty, "Repository should not be dirty after commit")
|
||||||
|
|
||||||
|
// Now edit the managed file (simulating the issue scenario)
|
||||||
|
err = os.WriteFile(testFile, []byte("def"), 0644)
|
||||||
|
suite.Require().NoError(err)
|
||||||
|
|
||||||
|
// Check status again - should detect dirty state
|
||||||
|
status, err = suite.lnk.Status()
|
||||||
|
suite.Require().NoError(err)
|
||||||
|
suite.Equal(1, status.Ahead)
|
||||||
|
suite.Equal(0, status.Behind)
|
||||||
|
suite.True(status.Dirty, "Repository should be dirty after editing managed file")
|
||||||
|
}
|
||||||
|
|
||||||
func TestCoreSuite(t *testing.T) {
|
func TestCoreSuite(t *testing.T) {
|
||||||
suite.Run(t, new(CoreTestSuite))
|
suite.Run(t, new(CoreTestSuite))
|
||||||
}
|
}
|
||||||
|
@@ -305,6 +305,7 @@ type StatusInfo struct {
|
|||||||
Ahead int
|
Ahead int
|
||||||
Behind int
|
Behind int
|
||||||
Remote string
|
Remote string
|
||||||
|
Dirty bool
|
||||||
}
|
}
|
||||||
|
|
||||||
// GetStatus returns the repository status relative to remote
|
// GetStatus returns the repository status relative to remote
|
||||||
@@ -315,6 +316,12 @@ func (g *Git) GetStatus() (*StatusInfo, error) {
|
|||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Check for uncommitted changes
|
||||||
|
dirty, err := g.HasChanges()
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to check for uncommitted changes: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
// Get the remote tracking branch
|
// Get the remote tracking branch
|
||||||
cmd := exec.Command("git", "rev-parse", "--abbrev-ref", "--symbolic-full-name", "@{u}")
|
cmd := exec.Command("git", "rev-parse", "--abbrev-ref", "--symbolic-full-name", "@{u}")
|
||||||
cmd.Dir = g.repoPath
|
cmd.Dir = g.repoPath
|
||||||
@@ -327,6 +334,7 @@ func (g *Git) GetStatus() (*StatusInfo, error) {
|
|||||||
Ahead: g.getAheadCount(remoteBranch),
|
Ahead: g.getAheadCount(remoteBranch),
|
||||||
Behind: 0, // Can't be behind if no upstream
|
Behind: 0, // Can't be behind if no upstream
|
||||||
Remote: remoteBranch,
|
Remote: remoteBranch,
|
||||||
|
Dirty: dirty,
|
||||||
}, nil
|
}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -336,6 +344,7 @@ func (g *Git) GetStatus() (*StatusInfo, error) {
|
|||||||
Ahead: g.getAheadCount(remoteBranch),
|
Ahead: g.getAheadCount(remoteBranch),
|
||||||
Behind: g.getBehindCount(remoteBranch),
|
Behind: g.getBehindCount(remoteBranch),
|
||||||
Remote: remoteBranch,
|
Remote: remoteBranch,
|
||||||
|
Dirty: dirty,
|
||||||
}, nil
|
}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Reference in New Issue
Block a user