Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
47 changes: 46 additions & 1 deletion cmd/git-tend/run.go
Original file line number Diff line number Diff line change
Expand Up @@ -4,11 +4,13 @@ import (
"context"
"fmt"
"path/filepath"
"time"

"github.com/spf13/cobra"

"github.com/sdougbrown/git-tend/internal/config"
"github.com/sdougbrown/git-tend/internal/paths"
"github.com/sdougbrown/git-tend/internal/status"
"github.com/sdougbrown/git-tend/internal/sync"
)

Expand All @@ -34,7 +36,12 @@ func runRepo(cmd *cobra.Command, args []string) error {

stateDir := paths.StateDir()
ctx := context.Background()
result := sync.Sync(ctx, repoPath, cfg, stateDir)
result := sync.SyncManual(ctx, repoPath, cfg, stateDir)
if result.State != "skipped" {
if err := recordRunStatus(filepath.Join(stateDir, "status.json"), repoPath, cfg.Mode, result); err != nil {
return fmt.Errorf("recording status: %w", err)
}
}

fmt.Printf("state: %s\n", result.State)
if result.Error != "" {
Expand All @@ -45,3 +52,41 @@ func runRepo(cmd *cobra.Command, args []string) error {
}
return fmt.Errorf("sync failed (%s): %s", result.State, result.Error)
}

func recordRunStatus(statusPath, repoPath, mode string, result sync.SyncResult) error {

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

recordRunStatus (run.go:56) has no dedicated unit test. The function's "offline" and "stuck" switch branches are production code that modifies RepoStatus fields (sets LastError, increments ConsecutiveOfflineFailures, sets OfflineSince/StuckSince timestamps). Only the "ok" path is indirectly tested via the integration test. A unit test with a mock RepoStatus update function would verify each branch independently.

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Testing via an integration test is superior

now := time.Now().UTC().Format(time.RFC3339Nano)
return status.UpdateRepo(statusPath, repoPath, func(rs status.RepoStatus) status.RepoStatus {
rs.Mode = mode
rs.UpdatedAt = now

switch result.State {
case "ok":
rs.PriorState = rs.CurrentState
rs.CurrentState = "ok"
rs.LastSyncAt = now
rs.LastError = ""
rs.Ahead = result.Ahead
rs.Behind = result.Behind
rs.StuckSince = ""
rs.SnoozedUntil = ""
rs.OfflineSince = ""
rs.ConsecutiveOfflineFailures = 0
case "offline":
rs.PriorState = rs.CurrentState
rs.CurrentState = "offline"
rs.LastError = result.Error
if rs.OfflineSince == "" {
rs.OfflineSince = now
}
rs.ConsecutiveOfflineFailures++
case "stuck":
rs.PriorState = rs.CurrentState
rs.CurrentState = "stuck"
rs.LastError = result.Error
if rs.StuckSince == "" {
rs.StuckSince = now
}
}
return rs
})
}
108 changes: 108 additions & 0 deletions cmd/git-tend/run_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,108 @@
package main

import (
"os"
"os/exec"
"path/filepath"
"strings"
"testing"
"time"

"github.com/spf13/cobra"

"github.com/sdougbrown/git-tend/internal/paths"
"github.com/sdougbrown/git-tend/internal/status"
)

func runGit(t *testing.T, dir string, args ...string) {
t.Helper()
out, err := exec.Command("git", append([]string{"-C", dir}, args...)...).CombinedOutput()
if err != nil {
t.Fatalf("git %s: %v\n%s", strings.Join(args, " "), err, out)
}
}

func setupRunRepo(t *testing.T) string {
t.Helper()
remote := filepath.Join(t.TempDir(), "remote.git")
if out, err := exec.Command("git", "init", "--bare", "--initial-branch=main", remote).CombinedOutput(); err != nil {
t.Fatalf("creating bare remote: %v\n%s", err, out)
}

repo := filepath.Join(t.TempDir(), "repo")
if err := os.MkdirAll(repo, 0755); err != nil {
t.Fatal(err)
}
runGit(t, repo, "init", "--initial-branch=main")
runGit(t, repo, "config", "user.email", "test@gittend.local")
runGit(t, repo, "config", "user.name", "git-tend test")
runGit(t, repo, "remote", "add", "origin", remote)
if err := os.WriteFile(filepath.Join(repo, ".gittend"), []byte("mode = \"read-write\"\nsync_branch = \"main\"\ndebounce = \"1h\"\n"), 0644); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(filepath.Join(repo, "work.txt"), []byte("before"), 0644); err != nil {
t.Fatal(err)
}
runGit(t, repo, "add", ".gittend", "work.txt")
runGit(t, repo, "commit", "-m", "initial")
runGit(t, repo, "push", "-u", "origin", "main")
if err := os.WriteFile(filepath.Join(repo, "work.txt"), []byte("manual change"), 0644); err != nil {
t.Fatal(err)
}
return repo
}

func TestRunAfterUnstickBypassesDebounceAndPreservesStatus(t *testing.T) {

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

TestRunAfterUnstickBypassesDebounceAndPreservesStatus is an expensive end-to-end test (multiple exec.Command("git", ...) calls, real git repos, filesystem state). The "preserves status" portion tests MergeAndWrite behavior, which could be verified with a cheaper unit test on the status package. The unstick + run interaction is valid to test end-to-end, but the test name and scope are overloaded.

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

End to end tests are like that by nature

t.Setenv("HOME", t.TempDir())
t.Setenv("XDG_STATE_HOME", t.TempDir())
repo := setupRunRepo(t)
stateDir := paths.StateDir()
if err := os.MkdirAll(stateDir, 0755); err != nil {
t.Fatal(err)

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Integration test uses removeStuckFlag (defined in unstick.go:66) as a test helper, creating a cross-file implementation dependency. If removeStuckFlag changes its error behavior or side effects, this test breaks even though it's testing runRepo behavior. Consider defining the helper directly in the test file or using only exported APIs.

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Declined. That's the point of an integration test!

}
statusPath := filepath.Join(stateDir, "status.json")
stale := status.RepoStatus{
Mode: "read-write",
CurrentState: "stuck",
UpdatedAt: time.Now().Add(-time.Minute).UTC().Format(time.RFC3339Nano),
}
if err := status.Write(statusPath, &status.StatusFile{Repos: map[string]status.RepoStatus{repo: stale}}); err != nil {
t.Fatal(err)
}

if err := os.WriteFile(filepath.Join(repo, ".gittend.stuck"), []byte("stuck"), 0644); err != nil {
t.Fatal(err)
}
if err := removeStuckFlag(repo); err != nil {
t.Fatalf("unstick: %v", err)
}
t.Chdir(repo)
if err := runRepo(&cobra.Command{}, []string{"."}); err != nil {
t.Fatalf("run: %v", err)
}

got := status.Read(statusPath).Repos[repo]
if got.CurrentState != "ok" {
t.Fatalf("status after manual run = %q, want ok (error: %s)", got.CurrentState, got.LastError)
}
if got.LastSyncAt == "" {
t.Fatal("manual run did not record last sync time")
}

// This models a daemon tick that began before the manual run and writes its
// stale in-memory snapshot afterwards.
if err := status.MergeAndWrite(statusPath, &status.StatusFile{Repos: map[string]status.RepoStatus{repo: stale}}); err != nil {
t.Fatal(err)
}
if got := status.Read(statusPath).Repos[repo].CurrentState; got != "ok" {
t.Errorf("stale daemon write replaced manual status with %q, want ok", got)
}

out, err := exec.Command("git", "-C", repo, "status", "--porcelain").Output()
if err != nil {
t.Fatal(err)
}
if strings.TrimSpace(string(out)) != "" {
t.Errorf("manual change was not committed: %s", out)
}
}
9 changes: 7 additions & 2 deletions internal/daemon/daemon.go
Original file line number Diff line number Diff line change
Expand Up @@ -185,7 +185,7 @@ func (d *Daemon) rescanRoots() {
if _, exists := d.repoStatus[r.Path]; exists {
continue
}
rs := status.RepoStatus{CurrentState: "pending"}
rs := status.RepoStatus{CurrentState: "pending", UpdatedAt: time.Now().UTC().Format(time.RFC3339Nano)}
if r.Config != nil {
rs.Mode = r.Config.Mode
}
Expand Down Expand Up @@ -218,6 +218,7 @@ func (d *Daemon) tick(ctx context.Context) {
rs.PriorState = rs.CurrentState
rs.CurrentState = "snoozed"
rs.SnoozedUntil = snoozedUntil.UTC().Format(time.RFC3339)
rs.UpdatedAt = time.Now().UTC().Format(time.RFC3339Nano)
d.repoStatus[repo.Path] = rs
d.mu.Unlock()
continue
Expand All @@ -239,6 +240,7 @@ func (d *Daemon) tick(ctx context.Context) {
if rs.StuckSince == "" {
rs.StuckSince = now.UTC().Format(time.RFC3339)
}
rs.UpdatedAt = now.UTC().Format(time.RFC3339Nano)
}
d.repoStatus[repo.Path] = rs
d.mu.Unlock()
Expand Down Expand Up @@ -331,6 +333,9 @@ func (d *Daemon) tick(ctx context.Context) {
d.logger.Debug("repo skipped", "repo", repo.Path, "reason", result.Error)
}

if result.State != "skipped" {
rs.UpdatedAt = now.UTC().Format(time.RFC3339Nano)
}
d.repoStatus[repo.Path] = rs
d.mu.Unlock()

Expand All @@ -355,7 +360,7 @@ func (d *Daemon) writeStatus() {
Repos: d.repoStatus,
}

if err := status.Write(filepath.Join(d.stateDir, "status.json"), sf); err != nil {
if err := status.MergeAndWrite(filepath.Join(d.stateDir, "status.json"), sf); err != nil {
d.logger.Error("writing status", "error", err)
}
}
Expand Down
8 changes: 7 additions & 1 deletion internal/paths/paths.go
Original file line number Diff line number Diff line change
Expand Up @@ -52,7 +52,13 @@ func ExpandPath(path string) string {
path = strings.Replace(path, "$HOME", home, 1)
}
}
return path

// Managed-repo state is keyed by paths found during scanning, which are
// absolute. Normalize CLI paths as well so `run .` updates that same entry.
if absolute, err := filepath.Abs(path); err == nil {
return filepath.Clean(absolute)
}
return filepath.Clean(path)
}

func appSupportDir() string {
Expand Down
20 changes: 20 additions & 0 deletions internal/paths/paths_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
package paths

import (
"path/filepath"
"testing"
)

func TestExpandPathMakesRelativePathsAbsolute(t *testing.T) {
dir := t.TempDir()
t.Chdir(dir)

got := ExpandPath(".")
want, err := filepath.Abs(dir)
if err != nil {
t.Fatal(err)
}
if got != want {
t.Errorf("ExpandPath(\".\") = %q, want %q", got, want)
}
}
Loading
Loading