diff --git a/cmd/root_test.go b/cmd/root_test.go index ffc8286..1eeae6e 100644 --- a/cmd/root_test.go +++ b/cmd/root_test.go @@ -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)) } diff --git a/cmd/status.go b/cmd/status.go index b35249f..8544d7a 100644 --- a/cmd/status.go +++ b/cmd/status.go @@ -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" +} diff --git a/internal/core/lnk.go b/internal/core/lnk.go index b9c3f1e..be4f28c 100644 --- a/internal/core/lnk.go +++ b/internal/core/lnk.go @@ -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 } diff --git a/internal/core/lnk_test.go b/internal/core/lnk_test.go index dc899da..cde65fc 100644 --- a/internal/core/lnk_test.go +++ b/internal/core/lnk_test.go @@ -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)) } diff --git a/internal/git/git.go b/internal/git/git.go index 9ab5f15..7b3c2f3 100644 --- a/internal/git/git.go +++ b/internal/git/git.go @@ -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 }