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
3 changes: 3 additions & 0 deletions .golangci.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -79,6 +79,9 @@ linters:
forbidigo:
analyze-types: true
forbid:
- pattern: '^os\.Getwd$'
msg: "os.Getwd() breaks when running from subdirectories - use paths.RepoRoot() for git-relative paths"
pkg: '^os$'
- pattern: '^.*\.Reset$'
msg: "go-git Reset deletes .gitignored dirs - use HardResetWithProtection() from common.go"
pkg: 'github\.com/go-git/go-git'
Expand Down
14 changes: 12 additions & 2 deletions cmd/entire/cli/agent/claudecode/claude.go
Original file line number Diff line number Diff line change
Expand Up @@ -44,12 +44,22 @@ func (c *ClaudeCodeAgent) Description() string {

// DetectPresence checks if Claude Code is configured in the repository.
func (c *ClaudeCodeAgent) DetectPresence() (bool, error) {
// Get repo root to check for .claude directory
// This is needed because the CLI may be run from a subdirectory
repoRoot, err := paths.RepoRoot()
if err != nil {
// Not in a git repo, fall back to CWD-relative check
repoRoot = "."
}

// Check for .claude directory
if _, err := os.Stat(".claude"); err == nil {
claudeDir := filepath.Join(repoRoot, ".claude")
if _, err := os.Stat(claudeDir); err == nil {
return true, nil
}
// Check for .claude/settings.json
if _, err := os.Stat(".claude/settings.json"); err == nil {
settingsFile := filepath.Join(repoRoot, ".claude", "settings.json")
if _, err := os.Stat(settingsFile); err == nil {
return true, nil
}
return false, nil
Expand Down
22 changes: 17 additions & 5 deletions cmd/entire/cli/agent/claudecode/hooks.go
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import (
"strings"

"entire.io/cli/cmd/entire/cli/agent"
"entire.io/cli/cmd/entire/cli/paths"
)

// Ensure ClaudeCodeAgent implements HookSupport and HookHandler
Expand Down Expand Up @@ -57,12 +58,18 @@ var entireHookPrefixes = []string{
// If force is true, removes existing Entire hooks before installing.
// Returns the number of hooks installed.
func (c *ClaudeCodeAgent) InstallHooks(localDev bool, force bool) (int, error) {
cwd, err := os.Getwd()
// Use repo root instead of CWD to find .claude directory
// This ensures hooks are installed correctly when run from a subdirectory
repoRoot, err := paths.RepoRoot()
if err != nil {
return 0, fmt.Errorf("failed to get current directory: %w", err)
// Fallback to CWD if not in a git repo (e.g., during tests)
repoRoot, err = os.Getwd() //nolint:forbidigo // Intentional fallback when RepoRoot() fails (tests run outside git repos)
if err != nil {
return 0, fmt.Errorf("failed to get current directory: %w", err)
}
}

settingsPath := filepath.Join(cwd, ".claude", ClaudeSettingsFileName)
settingsPath := filepath.Join(repoRoot, ".claude", ClaudeSettingsFileName)

// Read existing settings if they exist
var settings ClaudeSettings
Expand Down Expand Up @@ -210,8 +217,13 @@ func (c *ClaudeCodeAgent) UninstallHooks() error {

// AreHooksInstalled checks if Entire hooks are installed.
func (c *ClaudeCodeAgent) AreHooksInstalled() bool {
settingsPath := ".claude/" + ClaudeSettingsFileName
data, err := os.ReadFile(settingsPath)
// Use repo root to find .claude directory when run from a subdirectory
repoRoot, err := paths.RepoRoot()
if err != nil {
repoRoot = "." // Fallback to CWD if not in a git repo
}
settingsPath := filepath.Join(repoRoot, ".claude", ClaudeSettingsFileName)
data, err := os.ReadFile(settingsPath) //nolint:gosec // path is constructed from repo root + fixed path
if err != nil {
return false
}
Expand Down
15 changes: 13 additions & 2 deletions cmd/entire/cli/checkpoint/temporary.go
Original file line number Diff line number Diff line change
Expand Up @@ -500,6 +500,15 @@ func (s *GitStore) buildTreeWithChanges(
modifiedFiles, deletedFiles []string,
metadataDir, metadataDirAbs string,
) (plumbing.Hash, error) {
// Get repo root for resolving file paths
// This is critical because fileExists() and createBlobFromFile() use os.Stat()
// which resolves relative to CWD. The modifiedFiles are repo-relative paths,
// so we must resolve them against repo root, not CWD.
repoRoot, err := paths.RepoRoot()
if err != nil {
return plumbing.ZeroHash, fmt.Errorf("failed to get repo root: %w", err)
}

// Get the base tree
baseTree, err := s.repo.TreeObject(baseTreeHash)
if err != nil {
Expand All @@ -519,12 +528,14 @@ func (s *GitStore) buildTreeWithChanges(

// Add/update modified files
for _, file := range modifiedFiles {
if !fileExists(file) {
// Resolve path relative to repo root for filesystem operations
absPath := filepath.Join(repoRoot, file)
if !fileExists(absPath) {
delete(entries, file)
continue
}

blobHash, mode, err := createBlobFromFile(s.repo, file)
blobHash, mode, err := createBlobFromFile(s.repo, absPath)
if err != nil {
// Skip files that can't be staged (may have been deleted since detection)
continue
Expand Down
12 changes: 7 additions & 5 deletions cmd/entire/cli/resume.go
Original file line number Diff line number Diff line change
Expand Up @@ -361,13 +361,15 @@ func resumeSession(sessionID, checkpointID string) error {
}
}

// Get session directory for this agent
cwd, err := os.Getwd()
// Get repo root for session directory lookup
// Use repo root instead of CWD because Claude stores sessions per-repo,
// and running from a subdirectory would look up the wrong session directory
repoRoot, err := paths.RepoRoot()
if err != nil {
return fmt.Errorf("failed to get current directory: %w", err)
return fmt.Errorf("failed to get repository root: %w", err)
}

sessionDir, err := ag.GetSessionDir(cwd)
sessionDir, err := ag.GetSessionDir(repoRoot)
if err != nil {
return fmt.Errorf("failed to determine session directory: %w", err)
}
Expand Down Expand Up @@ -396,7 +398,7 @@ func resumeSession(sessionID, checkpointID string) error {
agentSession := &agent.AgentSession{
SessionID: agentSessionID,
AgentName: ag.Name(),
RepoPath: cwd,
RepoPath: repoRoot,
SessionRef: sessionLogPath,
NativeData: logContent,
}
Expand Down
30 changes: 18 additions & 12 deletions cmd/entire/cli/rewind.go
Original file line number Diff line number Diff line change
Expand Up @@ -561,14 +561,16 @@ func restoreSessionTranscript(transcriptFile, sessionID string) error {
return fmt.Errorf("failed to get agent: %w", err)
}

// Get current working directory for agent's session directory lookup
cwd, err := os.Getwd()
// Get repo root for agent's session directory lookup
// Use repo root instead of CWD because Claude stores sessions per-repo,
// and running from a subdirectory would look up the wrong session directory
repoRoot, err := paths.RepoRoot()
if err != nil {
return fmt.Errorf("failed to get current directory: %w", err)
return fmt.Errorf("failed to get repository root: %w", err)
}

// Get agent's session storage directory
sessionDir, err := ag.GetSessionDir(cwd)
sessionDir, err := ag.GetSessionDir(repoRoot)
if err != nil {
return fmt.Errorf("failed to get agent session directory: %w", err)
}
Expand Down Expand Up @@ -599,14 +601,16 @@ func restoreSessionTranscriptFromStrategy(strat strategy.Strategy, checkpointID,
return "", fmt.Errorf("failed to get agent: %w", err)
}

// Get current working directory for agent's session directory lookup
cwd, err := os.Getwd()
// Get repo root for agent's session directory lookup
// Use repo root instead of CWD because Claude stores sessions per-repo,
// and running from a subdirectory would look up the wrong session directory
repoRoot, err := paths.RepoRoot()
if err != nil {
return "", fmt.Errorf("failed to get current directory: %w", err)
return "", fmt.Errorf("failed to get repository root: %w", err)
}

// Get agent's session storage directory
agentSessionDir, err := ag.GetSessionDir(cwd)
agentSessionDir, err := ag.GetSessionDir(repoRoot)
if err != nil {
return "", fmt.Errorf("failed to get agent session directory: %w", err)
}
Expand Down Expand Up @@ -663,14 +667,16 @@ func restoreTaskCheckpointTranscript(strat strategy.Strategy, point strategy.Rew
// Truncate at checkpoint UUID
truncated := TruncateTranscriptAtUUID(transcript, checkpointUUID)

// Get current working directory for agent's session directory lookup
cwd, err := os.Getwd()
// Get repo root for agent's session directory lookup
// Use repo root instead of CWD because Claude stores sessions per-repo,
// and running from a subdirectory would look up the wrong session directory
repoRoot, err := paths.RepoRoot()
if err != nil {
return fmt.Errorf("failed to get current directory: %w", err)
return fmt.Errorf("failed to get repository root: %w", err)
}

// Get agent's session storage directory
agentSessionDir, err := ag.GetSessionDir(cwd)
agentSessionDir, err := ag.GetSessionDir(repoRoot)
if err != nil {
return fmt.Errorf("failed to get agent session directory: %w", err)
}
Expand Down
12 changes: 7 additions & 5 deletions cmd/entire/cli/session.go
Original file line number Diff line number Diff line change
Expand Up @@ -315,13 +315,15 @@ func runSessionResume(sessionID string) error {
// restoreAgentSession restores the session transcript using the agent abstraction.
// This is agent-agnostic and works with any registered agent.
func restoreAgentSession(ag agent.Agent, sessionID, checkpointID string, strat strategy.Strategy) error {
// Get session directory for this agent
cwd, err := os.Getwd()
// Get repo root for session directory lookup
// Use repo root instead of CWD because Claude stores sessions per-repo,
// and running from a subdirectory would look up the wrong session directory
repoRoot, err := paths.RepoRoot()
if err != nil {
return fmt.Errorf("failed to get current directory: %w", err)
return fmt.Errorf("failed to get repository root: %w", err)
}

sessionDir, err := ag.GetSessionDir(cwd)
sessionDir, err := ag.GetSessionDir(repoRoot)
if err != nil {
return fmt.Errorf("failed to determine session directory: %w", err)
}
Expand All @@ -348,7 +350,7 @@ func restoreAgentSession(ag agent.Agent, sessionID, checkpointID string, strat s
agentSession := &agent.AgentSession{
SessionID: agentSessionID,
AgentName: ag.Name(),
RepoPath: cwd,
RepoPath: repoRoot,
SessionRef: sessionLogPath,
NativeData: logContent,
}
Expand Down
11 changes: 10 additions & 1 deletion cmd/entire/cli/strategy/common.go
Original file line number Diff line number Diff line change
Expand Up @@ -721,9 +721,18 @@ const (
// The stageCtx parameter is used for user-facing messages to indicate whether
// this is staging for a session checkpoint or a task checkpoint.
func StageFiles(worktree *git.Worktree, modified, newFiles, deleted []string, stageCtx StageFilesContext) {
// Get repo root for resolving file paths
// This is critical because fileExists() uses os.Stat() which resolves relative to CWD,
// but worktree.Add/Remove resolve relative to repo root. If CWD != repo root,
// fileExists() could return false for existing files, causing worktree.Remove()
// to incorrectly delete them.
repoRoot := worktree.Filesystem.Root()

// Stage modified files
for _, file := range modified {
if fileExists(file) {
// Resolve path relative to repo root for existence check
absPath := filepath.Join(repoRoot, file)
if fileExists(absPath) {
if _, err := worktree.Add(file); err != nil {
fmt.Fprintf(os.Stderr, " Failed to stage %s: %v\n", file, err)
} else {
Expand Down
10 changes: 6 additions & 4 deletions cmd/entire/cli/strategy/manual_commit_rewind.go
Original file line number Diff line number Diff line change
Expand Up @@ -597,13 +597,15 @@ func (s *ManualCommitStrategy) RestoreLogsOnly(point RewindPoint) error {
}
}

// Get current working directory for Claude project path
cwd, err := os.Getwd()
// Get repo root for Claude project path lookup
// Use repo root instead of CWD because Claude stores sessions per-repo,
// and running from a subdirectory would look up the wrong session directory
repoRoot, err := paths.RepoRoot()
if err != nil {
return fmt.Errorf("failed to get current directory: %w", err)
return fmt.Errorf("failed to get repository root: %w", err)
}

claudeProjectDir, err := paths.GetClaudeProjectDir(cwd)
claudeProjectDir, err := paths.GetClaudeProjectDir(repoRoot)
if err != nil {
return fmt.Errorf("failed to get Claude project directory: %w", err)
}
Expand Down
23 changes: 19 additions & 4 deletions cmd/entire/cli/strategy/push_common.go
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ import (
"time"

"entire.io/cli/cmd/entire/cli/checkpoint"
"entire.io/cli/cmd/entire/cli/paths"

"github.com/go-git/go-git/v5"
"github.com/go-git/go-git/v5/plumbing"
Expand Down Expand Up @@ -76,13 +77,23 @@ func hasUnpushedSessionsCommon(repo *git.Repository, remote string, localHash pl
// getPushSessionsConfig reads the push_sessions setting from strategy options.
// Checks settings.local.json first (user preference), then settings.json (shared).
func getPushSessionsConfig() string {
// Use repo root to find settings files when run from a subdirectory
localSettingsPath, err := paths.AbsPath(".entire/settings.local.json")
if err != nil {
localSettingsPath = ".entire/settings.local.json" // Fallback
}
sharedSettingsPath, err := paths.AbsPath(".entire/settings.json")
if err != nil {
sharedSettingsPath = ".entire/settings.json" // Fallback
}

// Try local settings first (user preference, not committed)
if val := readPushSessionsFromFile(".entire/settings.local.json"); val != "" {
if val := readPushSessionsFromFile(localSettingsPath); val != "" {
return val
}

// Fall back to shared settings
if val := readPushSessionsFromFile(".entire/settings.json"); val != "" {
if val := readPushSessionsFromFile(sharedSettingsPath); val != "" {
return val
}

Expand Down Expand Up @@ -117,11 +128,15 @@ func readPushSessionsFromFile(settingsPath string) string {
// setPushSessionsConfig saves the push_sessions setting to settings.local.json.
// This is a user preference that should not be committed to the repository.
func setPushSessionsConfig(value string) error {
localSettingsFile := ".entire/settings.local.json"
// Use repo root to find settings file when run from a subdirectory
localSettingsFile, err := paths.AbsPath(".entire/settings.local.json")
if err != nil {
localSettingsFile = ".entire/settings.local.json" // Fallback
}

// Read existing local settings or start fresh
var settings map[string]interface{}
data, err := os.ReadFile(localSettingsFile)
data, err := os.ReadFile(localSettingsFile) //nolint:gosec // Path is controlled
if err != nil {
if !os.IsNotExist(err) {
return fmt.Errorf("failed to read local settings: %w", err)
Expand Down