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
19 changes: 19 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,25 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

## [Unreleased]

### Added
- `--lock PORT` now shows directory in success message (#77)
- New format: `Locked port 3001 for 'main' in ~/project`

### Changed
- Smart `--force` logic for `--lock PORT` (#77)
- Free + unlocked port from another directory: allowed without `--force` (abandoned allocation)
- Free + locked port from another directory: requires `--force`
- Busy port from another directory: blocked completely (stop the service first)
- Busy unallocated port: requires `--force` (user takes responsibility)

### Fixed
- Locked+busy port now correctly returned by `port-selector` (#77)
- Previously created new allocation instead of returning user's running service
- New priority: locked+free > locked+busy > unlocked+free > unlocked+busy(skip)
- Locking new port now correctly unlocks old locked port for same directory+name (#77)
- Invariant: at most one locked port per directory+name combination
- Old allocation is preserved (only unlocked), not deleted

## [0.9.5] - 2026-02-02

### Added
Expand Down
24 changes: 18 additions & 6 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -319,18 +319,30 @@ port-selector --unlock 3005
When using `--lock <PORT>` with a specific port number:
- If the port is not allocated, it will be allocated to the current directory AND locked
- This is useful when you want a specific port for a new project
- The port must be free and within the configured range
- If the port is allocated to another directory, an error is shown with hint to use `--force`
- The port must be within the configured range

Smart `--force` behavior when the port belongs to another directory:
- **Free + unlocked**: reassigned without `--force` (abandoned allocation)
- **Free + locked**: requires `--force` to reassign
- **Busy (any)**: blocked completely — stop the service first

When locking an unallocated busy port:
- Requires `--force` (you take responsibility for the conflict)

```bash
# Port locked by another directory - requires --force:
port-selector --lock 3006
# error: port 3006 is allocated to ~/code/other-project
# error: port 3006 is locked by ~/code/other-project
# use --lock 3006 --force to reassign it to current directory

# Force reassign from another directory:
# Force reassign locked port:
port-selector --lock 3006 --force
# warning: port 3006 was allocated to ~/code/other-project
# Reassigned and locked port 3006 for 'main'
# Reassigned and locked port 3006 for 'main' in ~/current-project

# Port busy on another directory - cannot reassign:
port-selector --lock 3006 --force
# error: port 3006 is in use by ~/code/other-project; stop the service first
```

When a port is locked:
Expand Down Expand Up @@ -406,7 +418,7 @@ Options:
-l, --list List all port allocations
-c, --lock [PORT] Lock port for current directory and name (or specified port)
-u, --unlock [PORT] Unlock port for current directory and name (or specified port)
--force, -f Force reassign port from another directory (use with --lock PORT)
--force, -f Force lock a busy port or locked port from another directory
--forget Clear all port allocations for current directory
--forget --name NAME Clear port allocation for current directory with specific name
--forget-all Clear all port allocations
Expand Down
24 changes: 18 additions & 6 deletions README.ru.md
Original file line number Diff line number Diff line change
Expand Up @@ -319,18 +319,30 @@ port-selector --unlock 3005
При использовании `--lock <PORT>` с конкретным номером порта:
- Если порт не выделен, он будет выделен текущей директории И заблокирован
- Это удобно, когда вы хотите конкретный порт для нового проекта
- Порт должен быть свободен и находиться в настроенном диапазоне
- Если порт уже выделен другой директории, выдаётся ошибка с подсказкой использовать `--force`
- Порт должен находиться в настроенном диапазоне

Умная логика `--force`, когда порт принадлежит другой директории:
- **Свободен + разблокирован**: переназначается без `--force` (заброшенная аллокация)
- **Свободен + заблокирован**: требуется `--force`
- **Занят (любой)**: блокируется полностью — сначала остановите сервис

При блокировке занятого порта без аллокации:
- Требуется `--force` (вы берёте ответственность за конфликт)

```bash
# Порт заблокирован другой директорией - нужен --force:
port-selector --lock 3006
# error: port 3006 is allocated to ~/code/other-project
# error: port 3006 is locked by ~/code/other-project
# use --lock 3006 --force to reassign it to current directory

# Принудительное переназначение из другой директории:
# Принудительное переназначение заблокированного порта:
port-selector --lock 3006 --force
# warning: port 3006 was allocated to ~/code/other-project
# Reassigned and locked port 3006 for 'main'
# Reassigned and locked port 3006 for 'main' in ~/current-project

# Порт занят другой директорией - переназначить нельзя:
port-selector --lock 3006 --force
# error: port 3006 is in use by ~/code/other-project; stop the service first
```

Когда порт заблокирован:
Expand Down Expand Up @@ -406,7 +418,7 @@ Options:
-l, --list Показать все аллокации портов
-c, --lock [PORT] Заблокировать порт для текущей директории и имени (или указанный порт)
-u, --unlock [PORT] Разблокировать порт для текущей директории и имени (или указанный порт)
--force, -f Принудительно переназначить порт из другой директории (с --lock PORT)
--force, -f Принудительно заблокировать занятый или чужой заблокированный порт
--forget Удалить все аллокации для текущей директории
--forget --name NAME Удалить аллокацию с указанным именем для текущей директории
--forget-all Удалить все аллокации
Expand Down
109 changes: 76 additions & 33 deletions cmd/port-selector/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -369,19 +369,27 @@ func runWithName(name string) error {
}

// Check if current directory already has an allocated port for this name
if existing := store.FindByDirectoryAndName(cwd, name); existing != nil {
debug.Printf("main", "found existing allocation for name %s: port %d", name, existing.Port)
// Check if the previously allocated port is free
if port.IsPortFree(existing.Port) {
debug.Printf("main", "existing port %d is free, reusing", existing.Port)
// Uses priority: locked+free > locked+busy > unlocked+free > unlocked+busy(skip)
if existing := store.FindByDirectoryAndNameWithPriority(cwd, name, port.IsPortFree); existing != nil {
debug.Printf("main", "found existing allocation for name %s: port %d (locked=%v)", name, existing.Port, existing.Locked)
// If locked+busy, the user's service is already running - return this port
// If free (locked or not), return this port for reuse
isFree := port.IsPortFree(existing.Port)
if isFree || existing.Locked {
if isFree {
debug.Printf("main", "existing port %d is free, reusing", existing.Port)
} else {
debug.Printf("main", "existing port %d is busy but locked (user's service running), returning it", existing.Port)
}
// Update last_used timestamp for the specific port being issued
if !store.UpdateLastUsedByPort(existing.Port) {
debug.Printf("main", "warning: UpdateLastUsedByPort failed for port %d", existing.Port)
fmt.Fprintf(os.Stderr, "warning: failed to update timestamp for port %d\n", existing.Port)
}
resultPort = existing.Port
return nil
}
debug.Printf("main", "existing port %d is busy, need new allocation", existing.Port)
debug.Printf("main", "existing port %d is busy and unlocked, need new allocation", existing.Port)
}

// Get last used port for round-robin behavior
Expand Down Expand Up @@ -595,43 +603,65 @@ func runSetLocked(name string, portArg int, locked bool, force bool) error {
// Print warning if port was reassigned from another directory
if reassignedFrom != "" {
fmt.Fprintf(os.Stderr, "warning: port %d was allocated to %s\n", targetPort, pathutil.ShortenHomePath(reassignedFrom))
fmt.Printf("Reassigned and locked port %d for '%s'\n", targetPort, name)
fmt.Printf("Reassigned and locked port %d for '%s' in %s\n", targetPort, name, pathutil.ShortenHomePath(cwd))
} else {
action := "Locked"
if !locked {
action = "Unlocked"
}
fmt.Printf("%s port %d for '%s'\n", action, targetPort, name)
fmt.Printf("%s port %d for '%s' in %s\n", action, targetPort, name, pathutil.ShortenHomePath(cwd))
}
return nil
}

// lockSpecificPort handles locking/unlocking a specific port number.
// Returns the port, the old directory (if reassigned), and any error.
//
// Decision Matrix for --lock PORT:
// - Require --force if: port is locked for another directory
// - Block completely (even with --force) if: port is busy on another directory
// - Allow without --force if: port not allocated, or allocated but free and unlocked
// - Special case: port busy but not in allocations — without --force error, with --force create allocation
func lockSpecificPort(store *allocations.Store, name string, portArg int, cwd string, locked bool, force bool) (int, string, error) {
isBusy := !port.IsPortFree(portArg)
alloc := store.FindByPort(portArg)

if alloc != nil {
// Port already allocated - check if it belongs to current directory
if alloc.Directory != cwd {
// Port belongs to another directory
// Port already allocated
if alloc.Directory == cwd {
// Port belongs to current directory - just update lock status
if !store.SetLockedByPort(portArg, locked) {
return 0, "", fmt.Errorf("internal error: allocation for port %d disappeared unexpectedly", portArg)
}
return portArg, "", nil
}

// Port belongs to another directory
if isBusy {
// Port is busy on another directory — block completely (even with --force)
return 0, "", fmt.Errorf("port %d is in use by %s; stop the service first",
portArg, pathutil.ShortenHomePath(alloc.Directory))
}

// Port is free — check if it's locked
if alloc.Locked {
// Require --force to reassign locked port
if !force {
return 0, "", fmt.Errorf("port %d is allocated to %s\n use --lock %d --force to reassign it to current directory",
return 0, "", fmt.Errorf("port %d is locked by %s\n use --lock %d --force to reassign it to current directory",
portArg, pathutil.ShortenHomePath(alloc.Directory), portArg)
}
// --force: reassign port to current directory
oldDir := alloc.Directory
store.RemoveByPort(portArg)
store.SetAllocationWithName(cwd, portArg, name)
if !store.SetLockedByPort(portArg, true) {
return 0, "", fmt.Errorf("internal error: failed to lock port %d after reassignment", portArg)
}
return portArg, oldDir, nil
}
// Port belongs to current directory - just update lock status
if !store.SetLockedByPort(portArg, locked) {
return 0, "", fmt.Errorf("internal error: allocation for port %d disappeared unexpectedly", portArg)
// Port is free and (unlocked OR --force provided) — allow reassignment
oldDir := alloc.Directory
store.RemoveByPort(portArg)
store.SetAllocationWithName(cwd, portArg, name)
if !store.SetLockedByPort(portArg, true) {
return 0, "", fmt.Errorf("internal error: failed to lock port %d after reassignment", portArg)
}
return portArg, "", nil
// Unlock any previously locked ports for this directory+name (invariant: at most one locked)
// This is done AFTER locking the new port so old locked ports are preserved during SetAllocation
store.UnlockOtherLockedPorts(cwd, name, portArg)
return portArg, oldDir, nil
}

// Port not allocated yet
Expand All @@ -649,20 +679,28 @@ func lockSpecificPort(store *allocations.Store, name string, portArg int, cwd st
return 0, "", fmt.Errorf("port %d is outside configured range %d-%d", portArg, cfg.PortStart, cfg.PortEnd)
}

if !port.IsPortFree(portArg) {
if procInfo := port.GetPortProcess(portArg); procInfo != nil {
return 0, "", fmt.Errorf("port %d is in use by another process (%s)", portArg, procInfo)
if isBusy {
// Port busy but NOT in allocations
if !force {
if procInfo := port.GetPortProcess(portArg); procInfo != nil {
return 0, "", fmt.Errorf("port %d is in use by another process (%s)", portArg, procInfo)
}
return 0, "", fmt.Errorf("port %d is in use", portArg)
}
return 0, "", fmt.Errorf("port %d is in use by another process", portArg)
// With --force: create allocation even though port is busy (user takes responsibility)
}

// Allocate and lock the port for this directory and name
// This will replace any existing allocation for the same name
// SetAllocationWithName preserves locked ports (they won't be deleted)
store.SetAllocationWithName(cwd, portArg, name)
if !store.SetLockedByPort(portArg, true) {
return 0, "", fmt.Errorf("internal error: failed to lock port %d after allocation", portArg)
}

// Unlock any previously locked ports for this directory+name (invariant: at most one locked)
// This is done AFTER locking the new port so old locked ports are preserved during SetAllocation
store.UnlockOtherLockedPorts(cwd, name, portArg)

return portArg, "", nil
}

Expand Down Expand Up @@ -835,7 +873,7 @@ Options:
-l, --list List all port allocations
-c, --lock [PORT] Lock port for current directory and name (or specified port)
-u, --unlock [PORT] Unlock port for current directory and name (or specified port)
--force, -f Force reassign port from another directory (use with --lock PORT)
--force, -f Force lock a busy port or locked port from another directory
--forget Clear all port allocations for current directory
--forget --name NAME Clear port allocation for current directory with specific name
--forget-all Clear all port allocations
Expand Down Expand Up @@ -864,10 +902,15 @@ Port Locking:
Use this for long-running services.

Using --lock with a port number will allocate AND lock that port
to the current directory/name in one step (if the port is free).
to the current directory/name in one step.

When --lock PORT targets another directory's port:
- Free + unlocked: reassigned without --force (abandoned allocation)
- Free + locked: requires --force to reassign
- Busy (any): blocked completely — stop the service first

If the port is already allocated to another directory, an error is shown.
Use --force to reassign the port to the current directory.
When --lock PORT targets a busy unallocated port:
- Requires --force (you take responsibility for the conflict)

Configuration:
~/.config/port-selector/default.yaml
Expand Down
Loading
Loading