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
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -53,7 +53,7 @@ Make sure `~/.local/bin` is on your `PATH`, then install the daemon service:
git-tend install
```

On macOS this writes a launchd plist to `~/Library/LaunchAgents/com.dbrown.gittend.plist` and loads it. On Linux it writes a systemd user unit to `~/.config/systemd/user/git-tend.service` and enables it.
On macOS this writes a launchd plist to `~/Library/LaunchAgents/com.dougthings.gittend.plist` and loads it. On Linux it writes a systemd user unit to `~/.config/systemd/user/git-tend.service` and enables it.

If `~/.config/git-tend/config.toml` doesn't exist, `install` writes a commented template. When stdin is a TTY it first prompts for which directories to scan (default `~/Code`, comma-separated for multiple). When run non-interactively (CI, piped install) it skips the prompt and writes the default. Either way the config path is printed so you can edit it.

Expand Down
3 changes: 3 additions & 0 deletions cmd/git-tend/greet.go
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,9 @@ func runGreet(cmd *cobra.Command, args []string) error {
}

stateDir := paths.StateDir()
if err := os.MkdirAll(stateDir, 0755); err != nil {
return fmt.Errorf("creating state dir: %w", err)
}

greetLastPath := filepath.Join(stateDir, "greet.last")
today := time.Now().Format("2006-01-02")
Expand Down
3 changes: 2 additions & 1 deletion cmd/git-tend/greet_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import (

"github.com/spf13/cobra"

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

Expand All @@ -17,7 +18,7 @@ func TestGreetDateStamp(t *testing.T) {
t.Setenv("HOME", tmpHome)
t.Setenv("NO_COLOR", "1")

stateDir := filepath.Join(tmpHome, "Library", "Application Support", "git-tend")
stateDir := paths.StateDir()
os.MkdirAll(stateDir, 0755)

configDir := filepath.Join(tmpHome, ".config", "git-tend")
Expand Down
42 changes: 27 additions & 15 deletions cmd/git-tend/restart.go
Original file line number Diff line number Diff line change
@@ -1,15 +1,17 @@
package main

import (
"errors"
"fmt"
"os"
"os/exec"
"path/filepath"
"runtime"
"syscall"
"time"

"github.com/spf13/cobra"

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

Expand All @@ -19,29 +21,39 @@ func init() {

var restartCmd = &cobra.Command{
Use: "restart",
Short: "Stop the running daemon and let the service manager respawn it",
Long: `Send SIGTERM to the running daemon. The service manager (launchd on macOS,
systemd on Linux) respawns it against whatever binary the symlink/service file
points to. Use this after rebuilding to pick up new code, or any time you want
Short: "Restart the daemon via the service manager",
Long: `Restart the running daemon via systemd (Linux) or launchd (macOS).
Use this after rebuilding to pick up a new binary, or any time you want
a fresh daemon process. Does not fetch or rebuild — that's on you.`,
RunE: runRestart,
}

func runRestart(cmd *cobra.Command, args []string) error {
pidPath := filepath.Join(paths.StateDir(), "daemon.pid")
oldPid, err := readDaemonPid(pidPath)
if err != nil {
if err != nil && !os.IsNotExist(err) {
return err
}

proc, err := os.FindProcess(oldPid)
if err != nil {
return fmt.Errorf("finding process %d: %w", oldPid, err)
var smErr error
switch {
case install.IsMacOS():
smErr = exec.Command("launchctl", "kickstart", "-kp", install.LaunchdLabel).Run()
case install.IsLinux():
smErr = exec.Command("systemctl", "--user", "restart", "git-tend").Run()
default:
return fmt.Errorf("unsupported platform: %s", runtime.GOOS)
}
if err := proc.Signal(syscall.SIGTERM); err != nil {
return fmt.Errorf("sending SIGTERM to pid %d: %w", oldPid, err)

if smErr != nil {
return fmt.Errorf("service manager restart failed: %w (is the service loaded? try 'git-tend install')", smErr)
}

if oldPid > 0 {
fmt.Printf("restarting daemon (was pid %d)...\n", oldPid)
} else {
fmt.Println("restarting daemon...")
}
fmt.Printf("sent SIGTERM to pid %d, waiting for respawn...\n", oldPid)

deadline := time.Now().Add(10 * time.Second)
for time.Now().Before(deadline) {
Expand All @@ -60,14 +72,14 @@ func runRestart(cmd *cobra.Command, args []string) error {
return nil
}

return fmt.Errorf("daemon did not respawn within 10s — is the service loaded? try 'git-tend install'")
return fmt.Errorf("daemon did not come back within 10s — is the service loaded? try 'git-tend install'")
}

func readDaemonPid(path string) (int, error) {
data, err := os.ReadFile(path)
if err != nil {
if errors.Is(err, os.ErrNotExist) {
return 0, fmt.Errorf("daemon not running (pid file missing at %s)", path)
if os.IsNotExist(err) {
return 0, fmt.Errorf("pid file missing at %s", path)
}
return 0, fmt.Errorf("reading pid file %s: %w", path, err)
}
Expand Down
8 changes: 6 additions & 2 deletions cmd/git-tend/status.go
Original file line number Diff line number Diff line change
Expand Up @@ -26,8 +26,12 @@ func runStatus(cmd *cobra.Command, args []string) error {
stateDir := paths.StateDir()
sf := status.Read(filepath.Join(stateDir, "status.json"))

if sf == nil || len(sf.Repos) == 0 {
fmt.Println("No managed repos. Run 'git-tend daemon' first.")
if sf == nil {
fmt.Println("Daemon not running. Run 'git-tend install' or 'git-tend daemon'.")
return nil
}
if len(sf.Repos) == 0 {
fmt.Println("No managed repos found yet. If you just started the daemon, give it a minute to scan. Otherwise, make sure repos have a .gittend file inside them.")
return nil
}

Expand Down
50 changes: 50 additions & 0 deletions cmd/git-tend/status_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
package main

import (
"io"
"os"
"path/filepath"
"strings"
"testing"

"github.com/spf13/cobra"

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

func TestRunStatusEmptyRepos(t *testing.T) {
tmpHome := t.TempDir()
t.Setenv("HOME", tmpHome)

stateDir := filepath.Join(tmpHome, ".local", "state", "git-tend")
os.MkdirAll(stateDir, 0755)

sf := &status.StatusFile{
Repos: map[string]status.RepoStatus{},
}
status.Write(filepath.Join(stateDir, "status.json"), sf)

r, w, err := os.Pipe()
if err != nil {
t.Fatal(err)
}
origStdout := os.Stdout
os.Stdout = w

err = runStatus(&cobra.Command{}, nil)
if err != nil {
t.Fatalf("runStatus failed: %v", err)
}

w.Close()
os.Stdout = origStdout

data, err := io.ReadAll(r)
if err != nil {
t.Fatal(err)
}

if !strings.Contains(string(data), "No managed repos found yet") {
t.Errorf("output = %q, want it to contain %q", string(data), "No managed repos found yet")
}
}
34 changes: 33 additions & 1 deletion internal/daemon/daemon.go
Original file line number Diff line number Diff line change
Expand Up @@ -6,8 +6,11 @@ import (
"fmt"
"log/slog"
"os"
"os/exec"
"os/signal"
"path/filepath"
"runtime"
"strings"
"sync"
"syscall"
"time"
Expand Down Expand Up @@ -123,7 +126,7 @@ func (d *Daemon) acquirePID() error {
var existingPID int
fmt.Sscanf(string(data), "%d", &existingPID)
if existingPID > 0 {
if err := syscall.Kill(existingPID, 0); err == nil {
if err := syscall.Kill(existingPID, 0); err == nil && isOwnProcess(existingPID) {
return fmt.Errorf("daemon already running, pid=%d", existingPID)
}
}
Expand All @@ -132,6 +135,35 @@ func (d *Daemon) acquirePID() error {
return os.WriteFile(d.pidPath, []byte(fmt.Sprintf("%d\n", os.Getpid())), 0644)
}

// isOwnProcess checks whether pid belongs to a git-tend daemon.
//
// There is a narrow TOCTOU window after the caller's preceding Kill(pid, 0)
// succeeds: the process could exit and its PID be recycled before we read
// /proc/<pid>/comm or run ps. In practice this is extremely unlikely for a
// long-lived daemon and is an acceptable limitation of a simple pidfile scheme.
func isOwnProcess(pid int) bool {
if pid == os.Getpid() {
return true
}
switch runtime.GOOS {
case "linux":
data, err := os.ReadFile(fmt.Sprintf("/proc/%d/comm", pid))
if err != nil {
return false
}
return strings.TrimSpace(string(data)) == "git-tend"
case "darwin":
out, err := exec.Command("ps", "-p", fmt.Sprintf("%d", pid), "-o", "comm=").Output()
if err != nil {
return false
}
return filepath.Base(strings.TrimSpace(string(out))) == "git-tend"
default:
// Unknown platform: be conservative and treat any running PID as ours.
return true
}
}

func (d *Daemon) rescanRoots() {
d.mu.Lock()
defer d.mu.Unlock()
Expand Down
16 changes: 16 additions & 0 deletions internal/daemon/daemon_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -90,6 +90,22 @@ func TestPIDRefusal(t *testing.T) {
}
}

func TestIsOwnProcess(t *testing.T) {
if !isOwnProcess(os.Getpid()) {
t.Error("isOwnProcess(os.Getpid()) should return true")
}

cmd := exec.Command("sleep", "5")
if err := cmd.Start(); err != nil {
t.Fatal(err)
}
defer cmd.Process.Kill()

if isOwnProcess(cmd.Process.Pid) {
t.Errorf("isOwnProcess(%d) should return false for a non-git-tend process", cmd.Process.Pid)
}
}

func TestDaemonTickIntegration(t *testing.T) {
tempRoot := t.TempDir()

Expand Down
11 changes: 9 additions & 2 deletions internal/install/install.go
Original file line number Diff line number Diff line change
Expand Up @@ -12,12 +12,16 @@ import (
func IsMacOS() bool { return runtime.GOOS == "darwin" }
func IsLinux() bool { return runtime.GOOS == "linux" }

const (
LaunchdLabel = "com.dougthings.gittend"
)

const launchdPlistTemplate = `<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>Label</key>
<string>com.dbrown.gittend</string>
<string>` + LaunchdLabel + `</string>
<key>ProgramArguments</key>
<array>
<string>%s</string>
Expand Down Expand Up @@ -60,7 +64,7 @@ func launchdPlistPath() (string, error) {
if err != nil {
return "", err
}
return filepath.Join(home, "Library", "LaunchAgents", "com.dbrown.gittend.plist"), nil
return filepath.Join(home, "Library", "LaunchAgents", LaunchdLabel+".plist"), nil
}

func systemdUnitPath() (string, error) {
Expand Down Expand Up @@ -119,6 +123,9 @@ func LoadService() error {
return exec.Command("launchctl", "load", plistPath).Run()
}
if IsLinux() {
if err := exec.Command("systemctl", "--user", "daemon-reload").Run(); err != nil {
return err
}
return exec.Command("systemctl", "--user", "enable", "--now", "git-tend").Run()
}
return fmt.Errorf("unsupported platform: %s", runtime.GOOS)
Expand Down
2 changes: 1 addition & 1 deletion internal/install/install_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -30,7 +30,7 @@ func TestWriteLaunchdPlist(t *testing.T) {
t.Fatal(err)
}
content := string(data)
for _, want := range []string{"com.dbrown.gittend", "KeepAlive", "RunAtLoad"} {
for _, want := range []string{LaunchdLabel, "KeepAlive", "RunAtLoad"} {
if !strings.Contains(content, want) {
t.Errorf("plist missing %q", want)
}
Expand Down
19 changes: 19 additions & 0 deletions internal/notify/notify_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -3,10 +3,15 @@ package notify
import (
"os"
"path/filepath"
"runtime"
"testing"
)

func TestNotifyMacOS(t *testing.T) {
if runtime.GOOS != "darwin" {
t.Skip("skipping macOS-only test")
}

tmpDir := t.TempDir()
stubPath := filepath.Join(tmpDir, "osascript")
calledPath := filepath.Join(tmpDir, "called")
Expand All @@ -24,5 +29,19 @@ func TestNotifyMacOS(t *testing.T) {
}

func TestNotifyDoesNotCrash(t *testing.T) {
tmpDir := t.TempDir()
var stubName string
switch runtime.GOOS {
case "darwin":
stubName = "osascript"
case "linux":
stubName = "notify-send"
default:
t.Skip("unsupported platform")
}
stubPath := filepath.Join(tmpDir, stubName)
os.WriteFile(stubPath, []byte("#!/bin/sh\n"), 0755)
t.Setenv("PATH", tmpDir+":"+os.Getenv("PATH"))

Notify("test", "test body")
}
Loading