fix(status): detect and report uncommitted changes in repository status, fixes #5

This commit is contained in:
Yar Kravtsov
2025-05-25 07:35:16 +03:00
parent d02f112200
commit 84c507828d
5 changed files with 157 additions and 29 deletions

View File

@@ -3,6 +3,7 @@ package cmd
import (
"bytes"
"os"
"os/exec"
"path/filepath"
"strings"
"testing"
@@ -424,6 +425,49 @@ func (suite *CLITestSuite) TestSameBasenameFilesBug() {
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) {
suite.Run(t, new(CLITestSuite))
}

View File

@@ -11,7 +11,7 @@ func newStatusCmd() *cobra.Command {
return &cobra.Command{
Use: "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,
RunE: func(cmd *cobra.Command, args []string) error {
lnk := core.NewLnk()
@@ -20,37 +20,74 @@ func newStatusCmd() *cobra.Command {
return fmt.Errorf("failed to get status: %w", err)
}
if status.Ahead == 0 && status.Behind == 0 {
printf(cmd, "✅ \033[1;32mRepository is up to date\033[0m\n")
printf(cmd, " 📡 Synced with \033[36m%s\033[0m\n", status.Remote)
} 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")
}
if status.Dirty {
displayDirtyStatus(cmd, status)
return nil
}
if status.Ahead == 0 && status.Behind == 0 {
displayUpToDateStatus(cmd, status)
return nil
}
displaySyncStatus(cmd, status)
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"
}

View File

@@ -350,6 +350,7 @@ type StatusInfo struct {
Ahead int
Behind int
Remote string
Dirty bool
}
// Status returns the repository sync status
@@ -368,6 +369,7 @@ func (l *Lnk) Status() (*StatusInfo, error) {
Ahead: gitStatus.Ahead,
Behind: gitStatus.Behind,
Remote: gitStatus.Remote,
Dirty: gitStatus.Dirty,
}, nil
}

View File

@@ -480,6 +480,42 @@ func (suite *CoreTestSuite) TestSameBasenameSequentialAdd() {
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) {
suite.Run(t, new(CoreTestSuite))
}

View File

@@ -305,6 +305,7 @@ type StatusInfo struct {
Ahead int
Behind int
Remote string
Dirty bool
}
// GetStatus returns the repository status relative to remote
@@ -315,6 +316,12 @@ func (g *Git) GetStatus() (*StatusInfo, error) {
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
cmd := exec.Command("git", "rev-parse", "--abbrev-ref", "--symbolic-full-name", "@{u}")
cmd.Dir = g.repoPath
@@ -327,6 +334,7 @@ func (g *Git) GetStatus() (*StatusInfo, error) {
Ahead: g.getAheadCount(remoteBranch),
Behind: 0, // Can't be behind if no upstream
Remote: remoteBranch,
Dirty: dirty,
}, nil
}
@@ -336,6 +344,7 @@ func (g *Git) GetStatus() (*StatusInfo, error) {
Ahead: g.getAheadCount(remoteBranch),
Behind: g.getBehindCount(remoteBranch),
Remote: remoteBranch,
Dirty: dirty,
}, nil
}