Task 10: Conflict copy naming #10

Closed
opened 2026-09-10 17:16:16 +00:00 by Cordy · 1 comment
Owner

Depends on Task 9.

Goal

Derive the filename for a conflict copy. Policy from docs/design-spec.md §4: on conflict the remote version keeps the canonical path and the local version is renamed to this — so no bytes are ever discarded.

Files

  • Create: internal/sync/conflict.go, internal/sync/conflict_test.go

Produces

func ConflictName(path string, at time.Time) string

Format: name (conflicted copy YYYY-MM-DD HH-MM-SS).ext

Two subtleties

  1. Hyphens in the time, not colons. A colon is illegal in Windows filenames, so a naive 15:04:05 would generate a name that cannot be written on Windows. There is a test for exactly this.
  2. A leading dot is part of the stem, not an extension. .gitignore has no extension — the suffix goes at the end, not in the middle.

Steps

  • Write the failing test:
package sync

import (
	"testing"
	"time"
)

func TestConflictName(t *testing.T) {
	at := time.Date(2026, 9, 10, 14, 30, 5, 0, time.UTC)
	cases := []struct{ in, want string }{
		{"a/report.pdf", "a/report (conflicted copy 2026-09-10 14-30-05).pdf"},
		{"notes", "notes (conflicted copy 2026-09-10 14-30-05)"},
		{"a/archive.tar.gz", "a/archive.tar (conflicted copy 2026-09-10 14-30-05).gz"},
		{".gitignore", ".gitignore (conflicted copy 2026-09-10 14-30-05)"},
	}
	for _, c := range cases {
		if got := ConflictName(c.in, at); got != c.want {
			t.Errorf("ConflictName(%q) = %q, want %q", c.in, got, c.want)
		}
	}
}

func TestConflictNameIsLegalOnWindows(t *testing.T) {
	got := ConflictName("a.txt", time.Now())
	if err := IllegalOn(got, PlatformWindows); err != nil {
		t.Errorf("conflict name %q is illegal on Windows: %v", got, err)
	}
}
  • Run it, confirm it fails.
  • Implement. Split the directory off first, then find the extension with strings.LastIndex(base, ".") and require the index to be > 0 so dotfiles are handled correctly.
  • Run tests, confirm they pass.
  • Commit: git commit -s -m "feat(sync): conflict copy naming"

Acceptance criteria

  • All four naming cases match exactly.
  • The generated name passes IllegalOn(..., PlatformWindows).
Depends on Task 9. ## Goal Derive the filename for a conflict copy. Policy from `docs/design-spec.md` §4: on conflict the **remote** version keeps the canonical path and the **local** version is renamed to this — so no bytes are ever discarded. ## Files - Create: `internal/sync/conflict.go`, `internal/sync/conflict_test.go` ## Produces ```go func ConflictName(path string, at time.Time) string ``` Format: `name (conflicted copy YYYY-MM-DD HH-MM-SS).ext` ## Two subtleties 1. **Hyphens in the time, not colons.** A colon is illegal in Windows filenames, so a naive `15:04:05` would generate a name that cannot be written on Windows. There is a test for exactly this. 2. **A leading dot is part of the stem, not an extension.** `.gitignore` has no extension — the suffix goes at the end, not in the middle. ## Steps - [ ] **Write the failing test:** ```go package sync import ( "testing" "time" ) func TestConflictName(t *testing.T) { at := time.Date(2026, 9, 10, 14, 30, 5, 0, time.UTC) cases := []struct{ in, want string }{ {"a/report.pdf", "a/report (conflicted copy 2026-09-10 14-30-05).pdf"}, {"notes", "notes (conflicted copy 2026-09-10 14-30-05)"}, {"a/archive.tar.gz", "a/archive.tar (conflicted copy 2026-09-10 14-30-05).gz"}, {".gitignore", ".gitignore (conflicted copy 2026-09-10 14-30-05)"}, } for _, c := range cases { if got := ConflictName(c.in, at); got != c.want { t.Errorf("ConflictName(%q) = %q, want %q", c.in, got, c.want) } } } func TestConflictNameIsLegalOnWindows(t *testing.T) { got := ConflictName("a.txt", time.Now()) if err := IllegalOn(got, PlatformWindows); err != nil { t.Errorf("conflict name %q is illegal on Windows: %v", got, err) } } ``` - [ ] **Run it, confirm it fails.** - [ ] **Implement.** Split the directory off first, then find the extension with `strings.LastIndex(base, ".")` and require the index to be **> 0** so dotfiles are handled correctly. - [ ] **Run tests, confirm they pass.** - [ ] **Commit:** `git commit -s -m "feat(sync): conflict copy naming"` ## Acceptance criteria - All four naming cases match exactly. - The generated name passes `IllegalOn(..., PlatformWindows)`.
Cordy added this to the phase-1-engine milestone 2026-09-10 17:16:16 +00:00
Author
Owner

Done

What was built

  • internal/sync/conflict.go: ConflictName(path string, at time.Time) string, deriving name (conflicted copy YYYY-MM-DD HH-MM-SS).ext per spec §4 (remote keeps the canonical path, local is renamed).
  • Directory is split off first; extension is found via strings.LastIndex(base, ".") requiring index > 0, so a leading dot (.gitignore) stays part of the stem.
  • Time is rendered with hyphens (15-04-05), not colons, so the result is legal on Windows.
  • internal/sync/conflict_test.go is byte-identical to the test in issue #10's body (no comments existed to amend it).
  • No collision/disambiguation logic added — per R4, that belongs to the task(s) (#12/#13) that materialize conflict copies against real state.

Tests

  • TestConflictName — all four issue cases pass verbatim (a/report.pdf, notes, a/archive.tar.gz, .gitignore).
  • TestConflictNameIsLegalOnWindows — output passes IllegalOn(..., PlatformWindows).
  • Full suite, go vet, gofmt -l, go mod tidy clean; independently re-verified in a scratch clone during review (see progress.md CV1/CV2).
  • CI run #10 on 35e7696 — green, linux/arm64 (Go 1.25.5). go test -count=1 ./... all packages ok; no coverage instrumentation configured (no total: line to report).

Acceptance criteria

  • "All four naming cases match exactly." — Met: TestConflictName asserts all four issue cases byte-for-byte.
  • "The generated name passes IllegalOn(..., PlatformWindows)." — Met: TestConflictNameIsLegalOnWindows, since the hyphenated time format and space/paren suffix introduce no Windows-illegal characters.

Rulings

  • R12: commit subjects use the exact message given in the issue, always git commit -s, and carry Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> — both trailers present in 35e7696.
  • Task 10 (progress.md CV1): RED/GREEN transcripts and full-suite/vet/gofmt/tidy results reproduced independently in a scratch clone of 35e7696 — all confirmed, including mutation probes that catch a wrong time layout or a wrong >= 0 guard.
  • Task 10 (progress.md CV2): the red step (not visible in single-commit history) was reproduced by removing conflict.go from 35e7696 — fails to compile with undefined: ConflictName at the exact reported positions.
  • Task 10 (progress.md CV3): CI was the controller's gate for closing #10 under R11; 35e7696's only parent is origin/main, so it was pushed on its own and gated on this run's green result.

Deferred

  • Minor (review F1): the mandated suffix adds 37 bytes; a 244-byte legal base name can become a 282-byte conflict name, exceeding NAME_MAX 255 on ext4/APFS/NTFS. IllegalOn has no length check. Carried to #12/#13, which perform the actual move/rename.
  • Minor (review F2): the doc comment's "no bytes are ever discarded" claim holds only if the caller also checks the result isn't already taken (R4 disambiguation). Optional doc tightening, not a code change.

Implemented and reviewed by Claude (subagent-driven), landed on main after review and green CI.

**Done** - [`35e7696` feat(sync): conflict copy naming](http://192.168.10.245/Cordy/cairn-desktop/commit/35e7696442250e19365dcc21d71ce6e01e5a73a4) **What was built** - `internal/sync/conflict.go`: `ConflictName(path string, at time.Time) string`, deriving `name (conflicted copy YYYY-MM-DD HH-MM-SS).ext` per spec §4 (remote keeps the canonical path, local is renamed). - Directory is split off first; extension is found via `strings.LastIndex(base, ".")` requiring index `> 0`, so a leading dot (`.gitignore`) stays part of the stem. - Time is rendered with hyphens (`15-04-05`), not colons, so the result is legal on Windows. - `internal/sync/conflict_test.go` is byte-identical to the test in issue #10's body (no comments existed to amend it). - No collision/disambiguation logic added — per R4, that belongs to the task(s) (#12/#13) that materialize conflict copies against real state. **Tests** - `TestConflictName` — all four issue cases pass verbatim (`a/report.pdf`, `notes`, `a/archive.tar.gz`, `.gitignore`). - `TestConflictNameIsLegalOnWindows` — output passes `IllegalOn(..., PlatformWindows)`. - Full suite, `go vet`, `gofmt -l`, `go mod tidy` clean; independently re-verified in a scratch clone during review (see progress.md CV1/CV2). - CI run [#10](http://192.168.10.245/Cordy/cairn-desktop/actions/runs/10) on `35e7696` — green, `linux/arm64` (Go 1.25.5). `go test -count=1 ./...` all packages ok; no coverage instrumentation configured (no `total:` line to report). **Acceptance criteria** - "All four naming cases match exactly." — Met: `TestConflictName` asserts all four issue cases byte-for-byte. - "The generated name passes `IllegalOn(..., PlatformWindows)`." — Met: `TestConflictNameIsLegalOnWindows`, since the hyphenated time format and space/paren suffix introduce no Windows-illegal characters. **Rulings** - R12: commit subjects use the exact message given in the issue, always `git commit -s`, and carry `Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>` — both trailers present in 35e7696. - Task 10 (progress.md CV1): RED/GREEN transcripts and full-suite/vet/gofmt/tidy results reproduced independently in a scratch clone of 35e7696 — all confirmed, including mutation probes that catch a wrong time layout or a wrong `>= 0` guard. - Task 10 (progress.md CV2): the red step (not visible in single-commit history) was reproduced by removing conflict.go from 35e7696 — fails to compile with `undefined: ConflictName` at the exact reported positions. - Task 10 (progress.md CV3): CI was the controller's gate for closing #10 under R11; 35e7696's only parent is origin/main, so it was pushed on its own and gated on this run's green result. **Deferred** - Minor (review F1): the mandated suffix adds 37 bytes; a 244-byte legal base name can become a 282-byte conflict name, exceeding `NAME_MAX` 255 on ext4/APFS/NTFS. `IllegalOn` has no length check. Carried to #12/#13, which perform the actual move/rename. - Minor (review F2): the doc comment's "no bytes are ever discarded" claim holds only if the caller also checks the result isn't already taken (R4 disambiguation). Optional doc tightening, not a code change. _Implemented and reviewed by Claude (subagent-driven), landed on main after review and green CI._
Cordy closed this issue 2026-09-10 22:44:32 +00:00
Sign in to join this conversation.
No milestone
No project
No assignees
1 participant
Notifications
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".

No due date set.

Dependencies

No dependencies set.

Reference: Cordy/cairn-desktop#10
No description provided.